120 lines
5.7 KiB
Python
120 lines
5.7 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.
|
|
|
|
When this task continues a supervisor review chain, use its coordinator-issued project, branch, and pull request. A repair stays on that existing branch and PR; do not create a replacement. Review the current PR head, actual diff, and relevant test evidence before a verdict. A base-behind count alone is not a defect or retirement signal. Complete assigned delegated work before giving a final verdict, and report the precise reviewed commit. After a repair or CI failure, refresh the existing PR title and body through the signed mediator path so the current head has current handoff evidence.
|
|
|
|
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
|