Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions agent/verification_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,56 @@ def record_terminal_result(
)
if evidence is None:
return None
return _insert_evidence(evidence)


def record_verify_run(
*,
root: str | Path,
session_id: str | None = None,
ok: bool,
command: str = "hermes verify",
scope: str = "full",
output: str = "",
) -> Optional[dict[str, Any]]:
"""Record a completed ``hermes verify`` run as verification evidence.

Explicit CLI-side write: unlike :func:`record_terminal_result` there is
nothing to classify — the caller (the ``hermes verify`` command) already
knows the run was a verification pass and whether it succeeded. A passing
run marks the workspace ``passed`` for the verify-on-stop guard exactly
like a passing canonical test command would; a failing run records the
failure so the guard keeps asking for a fix.

``root`` is re-resolved through :func:`agent.coding_context.project_facts_for`
so the recorded workspace root matches what :func:`verification_status`
derives when the stop guard later looks the evidence up.
"""
try:
from agent.coding_context import project_facts_for

facts = project_facts_for(root)
except Exception:
facts = None

resolved = str(Path(root).resolve())
evidence = VerificationEvidence(
command=command,
canonical_command="hermes verify",
kind="verify",
scope=scope if scope in {"full", "targeted"} else "full",
status="passed" if ok else "failed",
exit_code=0 if ok else 1,
cwd=resolved,
root=str((facts or {}).get("root") or resolved),
session_id=str(session_id or "default"),
output_summary=_summarize_output(output),
)
return _insert_evidence(evidence)


def _insert_evidence(evidence: VerificationEvidence) -> dict[str, Any]:
"""Insert a classified evidence row and repoint the workspace state."""
created_at = _utc_now()
with _DB_LOCK:
with _transaction() as conn:
Expand Down
55 changes: 47 additions & 8 deletions agent/verification_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,30 @@ def _format_changed_paths(paths: list[str]) -> str:
return "\n".join(lines)


def _workspace_has_runnable_recipe(root: Any) -> bool:
"""Whether the workspace has a runtime verify recipe ``hermes verify`` can run.

True when a saved ``.hermes/environment.json`` manifest exists, or when
cheap static detection (:func:`agent.verify.recipes.detect_recipe`) finds a
recipe with a start command. Deliberately fail-silent and cheap — this only
decorates the nudge text; it must never break or slow the nudge path.
"""
if not root:
return False
try:
root_path = Path(str(root))
from agent.verify.environment import manifest_path

if manifest_path(root_path).is_file():
return True
from agent.verify.recipes import detect_recipe

recipe = detect_recipe(root_path)
return bool(recipe is not None and recipe.start)
except Exception:
return False


def _status_detail(status: dict[str, Any]) -> str:
state = str(status.get("status") or "unverified")
evidence = status.get("evidence") if isinstance(status.get("evidence"), dict) else None
Expand Down Expand Up @@ -248,16 +272,31 @@ def build_verify_on_stop_nudge(
+ (", ..." if len(verify_commands) > 3 else "")
+ "), read any failure, repair the code, and summarize what passed."
)
if _workspace_has_runnable_recipe(facts.get("root")):
command_instruction += (
" For a full check including a runtime boot (build + test + "
"start + readiness), prefer `hermes verify --json` — a passing "
"run records verification evidence for this workspace."
)
else:
temp_dir = os.path.realpath(tempfile.gettempdir())
command_instruction = (
"No canonical test/lint/build command was detected. Create a focused "
f"temporary verification script under `{temp_dir}` using an OS-safe "
"`tempfile` path with a `hermes-verify-` filename prefix, run it "
"against the changed behavior, clean it up when possible, and "
"summarize it explicitly as ad-hoc verification rather than suite "
"green."
)
if _workspace_has_runnable_recipe(facts.get("root")):
command_instruction = (
"No canonical test/lint/build command was detected, but the "
"project has a runnable verification recipe. Run `hermes verify "
"--json` (detect -> build -> test -> boot -> readiness poll); a "
"passing run records verification evidence for this workspace. "
"Read any failure, repair the code, and summarize what passed."
)
else:
command_instruction = (
"No canonical test/lint/build command was detected. Create a focused "
f"temporary verification script under `{temp_dir}` using an OS-safe "
"`tempfile` path with a `hermes-verify-` filename prefix, run it "
"against the changed behavior, clean it up when possible, and "
"summarize it explicitly as ad-hoc verification rather than suite "
"green."
)

return (
"[System: You edited code in this turn, but the workspace does not have "
Expand Down
38 changes: 38 additions & 0 deletions agent/verify/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Project verification subsystem.

Ported from superagent-ai/grok-cli's verify subsystem (scoped):
static run-recipe detection, a persisted environment manifest, and a
smoke-test runner used by the ``hermes verify`` CLI command.

Sources:
- https://github.com/superagent-ai/grok-cli/blob/main/src/verify/recipes.ts
- https://github.com/superagent-ai/grok-cli/blob/main/src/verify/environment.ts
"""

from agent.verify.environment import (
load_manifest,
load_or_detect,
manifest_path,
save_manifest,
)
from agent.verify.recipes import Recipe, detect_package_manager, detect_recipe
from agent.verify.runner import (
PhaseResult,
ReadinessResult,
VerifyResult,
run_verify,
)

__all__ = [
"Recipe",
"detect_recipe",
"detect_package_manager",
"load_manifest",
"save_manifest",
"load_or_detect",
"manifest_path",
"run_verify",
"PhaseResult",
"ReadinessResult",
"VerifyResult",
]
75 changes: 75 additions & 0 deletions agent/verify/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Environment manifest for project verification.

Ported from superagent-ai/grok-cli ``src/verify/environment.ts``.
The manifest lives at ``<project>/.hermes/environment.json`` and is the
user-editable source of truth: when present and valid it wins over fresh
static detection.
"""

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path

from agent.verify.recipes import Recipe, detect_recipe

MANIFEST_VERSION = 1
_MANIFEST_RELPATH = Path(".hermes") / "environment.json"


def manifest_path(root: Path) -> Path:
"""Path of the verify manifest for the project at ``root``."""
return Path(root) / _MANIFEST_RELPATH


def load_manifest(root: Path) -> Recipe | None:
"""Load the saved recipe from the manifest, tolerating malformed files.

Mirrors grok's ``loadVerifyEnvironment``: any read/parse/shape problem
returns ``None`` rather than raising, so a corrupt manifest degrades to
fresh detection instead of breaking ``hermes verify``.
"""
path = manifest_path(root)
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return None
try:
manifest = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(manifest, dict):
return None
# Accept both the wrapped {version, recipe} shape and a bare recipe.
recipe_raw = manifest.get("recipe", manifest)
return Recipe.from_dict(recipe_raw)


def save_manifest(root: Path, recipe: Recipe) -> Path:
"""Persist ``recipe`` as the project's verify manifest.

Writes the versioned wrapper shape (grok's ``saveVerifyEnvironment``
equivalent) and returns the manifest path.
"""
path = manifest_path(root)
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"version": MANIFEST_VERSION,
"recipe": recipe.to_dict(),
"updatedAt": datetime.now(timezone.utc).isoformat(),
}
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
return path


def load_or_detect(root: Path) -> tuple[Recipe | None, str]:
"""Return (recipe, source) where source is 'manifest' or 'detected'.

A saved manifest wins over fresh detection, matching grok-cli's
behavior where ``.grok/environment.json`` is the source of truth.
"""
saved = load_manifest(root)
if saved is not None:
return saved, "manifest"
return detect_recipe(root), "detected"
Loading
Loading