atlas-iac/services/hermes/scripts/cli_lane_prompt.py

118 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Prompt, artifact, and structured-event helpers for CLI workers."""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
from typing import Any
import cli_lane_goal
from cli_lane_files import atomic_json
def build_prompt(context: str, workspace: Path, handoff: str = "") -> str:
"""Create a bounded worker contract with an explicit machine-readable result."""
return f"""You are a durable coding worker managed by Hermes Kanban.
Work only on this objective and its acceptance criteria:
{context}
Workspace: {workspace}
{handoff}
Operate autonomously inside the workspace. Inspect before editing, preserve unrelated user changes, run proportionate tests, and do not claim completion without evidence. You have owner-level Kubernetes access in every namespace. Prefer Flux-tracked manifests for durable changes, but use kubectl, Flux, exec, port-forwarding, rollout operations, and existing Vault workflows when the objective or incident requires them. Persist any desired-state mutation back to Git. Do not force-push, hard-reset, clean untracked files, or expose credentials.
Return a final JSON object matching the supplied schema. Use status=incomplete when required work, tests, commands, commits, pushes, or verification are still running or remain to be done. Use status=blocked only when an obstacle prevents completion of the assigned task itself. Never use status=completed for a progress report. For review or diagnostic tasks, put defects and risks in findings; those findings can make the reviewed change unfit to ship without blocking completion of the review. The blockers array must be empty whenever status is completed. List changed files, tests run, durable artifact paths, findings, and task blockers explicitly.
"""
def git_handoff(workspace: Path, prior_output: str) -> str:
"""Build an explicit cross-provider handoff without transferring hidden state."""
def read_git(*args: str) -> str:
completed = subprocess.run(
["git", "-C", str(workspace), *args],
text=True,
capture_output=True,
timeout=30,
check=False,
)
return (completed.stdout or completed.stderr).strip()[-12000:]
return (
"\nCross-provider handoff from a failed or exhausted worker:\n"
f"Git status:\n{read_git('status', '--short', '--branch')}\n\n"
f"Current diff summary:\n{read_git('diff', '--stat')}\n\n"
f"Prior worker tail:\n{prior_output[-10000:]}\n"
"Reinspect the workspace and verify all inherited claims before continuing."
)
def workspace_artifacts(workspace: Path, values: Any) -> list[str]:
"""Return existing regular artifacts contained by the task worktree."""
if not isinstance(values, list):
return []
root = workspace.resolve()
artifacts: list[str] = []
for value in values:
if not isinstance(value, str) or not value.strip():
continue
candidate = Path(value.strip()).expanduser()
if not candidate.is_absolute():
candidate = root / candidate
try:
resolved = candidate.resolve(strict=True)
resolved.relative_to(root)
except (OSError, RuntimeError, ValueError):
continue
if resolved.is_file():
artifacts.append(str(resolved))
return list(dict.fromkeys(artifacts))
def _extract_json(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict) and value.get("status") in cli_lane_goal.RESULT_STATUSES:
return value
if not isinstance(value, str):
return None
candidates = [value]
match = re.search(r"\{.*\}", value, re.DOTALL)
if match:
candidates.append(match.group(0))
for candidate in candidates:
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
continue
if isinstance(parsed, dict) and parsed.get("status") in cli_lane_goal.RESULT_STATUSES:
return parsed
return None
def _event_payload(provider: str, line: str, state: dict[str, Any], state_file: Path) -> dict[str, Any] | None:
"""Persist provider session identifiers before interpreting final output."""
try:
event = json.loads(line)
except json.JSONDecodeError:
return None
if not isinstance(event, dict):
return None
if provider == "codex" and event.get("type") == "thread.started":
thread_id = event.get("thread_id") or event.get("thread", {}).get("id")
if thread_id:
state["codex_thread_id"] = str(thread_id)
atomic_json(state_file, state)
if provider == "claude" and event.get("session_id"):
state["claude_session_id"] = str(event["session_id"])
atomic_json(state_file, state)
for key in ("structured_output", "result", "output", "text"):
parsed = _extract_json(event.get(key))
if parsed:
return parsed
item = event.get("item")
if isinstance(item, dict):
for key in ("text", "content"):
parsed = _extract_json(item.get(key))
if parsed:
return parsed
return None