Neue Skills, Referenzen & OpenWiki-Doku integriert
Umfangreiche Erweiterung der Skill-Bibliothek: Neue Skills für Humanisierung (Englisch/PT-BR), Design-Validierung, AI-SEO und Coolify-Deployment inkl. Regelwerke, Presets, Pattern-Referenzen, Testfälle und Automatisierungsskripte. Zusätzliche Skills für Revenue-Centric Design, Pier Cloud, OKF, Lebenslauf- und LinkedIn-Optimierung sowie zahlreiche Referenzdateien, Checklisten und YAML/JSON/Markdown-Templates. Einführung einer vollständigen OpenWiki-Dokumentation mit Architektur-, Domain- und Workflow-Beschreibungen, zentralem Index und automatisierten Updates. Modularer Aufbau, restriktive Lizenzen und umfassende Qualitäts- und Evaluationsmechanismen für alle neuen Inhalte.
This commit is contained in:
822
.github/skills/loop-architect/scripts/looper.py
vendored
Normal file
822
.github/skills/loop-architect/scripts/looper.py
vendored
Normal file
@@ -0,0 +1,822 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loop-architect helper CLI.
|
||||
|
||||
This script belongs to the scaffolding side of loop-architect. It may detect
|
||||
installed CLIs, register invocation metadata, compile loop.yaml to
|
||||
loop.resolved.json, and render LOOP.md. It must not invoke model CLIs to do
|
||||
loop work.
|
||||
|
||||
Based on Looper by Kevin Simback (https://github.com/ksimback/looper), MIT License.
|
||||
Adapted for Kiro CLI by ft.ia.br.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_REDACTIONS = [".env", ".env.*", "secrets/**", "**/*.key"]
|
||||
REGISTRY_PATH = Path.home() / ".loop-architect" / "models.json"
|
||||
|
||||
MODEL_PROBES: dict[str, dict[str, Any]] = {
|
||||
"kiro": {
|
||||
"invoke": ["kiro-cli", "chat", "--trust-all-tools", "-p"],
|
||||
"probe": ["kiro-cli", "--version"],
|
||||
"local": False,
|
||||
"install": "Install Kiro CLI: https://kiro.dev/downloads/",
|
||||
"capabilities": ["headless", "goal", "subagent", "review_loop"],
|
||||
},
|
||||
"claude": {
|
||||
"invoke": ["claude", "-p"],
|
||||
"probe": ["claude", "--version"],
|
||||
"local": False,
|
||||
"install": "Install and authenticate the Claude CLI.",
|
||||
"capabilities": ["headless", "goal"],
|
||||
},
|
||||
"codex": {
|
||||
"invoke": ["codex", "exec"],
|
||||
"probe": ["codex", "--version"],
|
||||
"local": False,
|
||||
"install": "Install and authenticate the Codex CLI.",
|
||||
"capabilities": ["headless", "goal"],
|
||||
},
|
||||
"gemini": {
|
||||
"invoke": ["gemini", "-p"],
|
||||
"probe": ["gemini", "--version"],
|
||||
"local": False,
|
||||
"install": "Install and authenticate the Gemini CLI.",
|
||||
"capabilities": ["headless"],
|
||||
},
|
||||
"llm": {
|
||||
"invoke": ["llm"],
|
||||
"probe": ["llm", "--version"],
|
||||
"local": False,
|
||||
"install": "Install llm and configure a model/provider.",
|
||||
"capabilities": ["headless"],
|
||||
},
|
||||
"ollama": {
|
||||
"invoke": ["ollama", "run"],
|
||||
"probe": ["ollama", "--version"],
|
||||
"local": True,
|
||||
"install": "Install Ollama and pull a local model.",
|
||||
"capabilities": ["headless"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LooperError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError as exc:
|
||||
raise LooperError(
|
||||
"PyYAML is required to compile loop.yaml. Install with: python -m pip install PyYAML"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except OSError as exc:
|
||||
raise LooperError(f"Could not read {path}: {exc}") from exc
|
||||
except yaml.YAMLError as exc:
|
||||
raise LooperError(f"Could not parse YAML in {path}: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise LooperError(f"{path} must contain a YAML mapping at the top level")
|
||||
return data
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise LooperError(f"{path} must contain a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(to_jsonable(data), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def to_jsonable(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): to_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [to_jsonable(item) for item in value]
|
||||
if isinstance(value, (_dt.date, _dt.datetime)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def read_registry(path: Path = REGISTRY_PATH) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if not isinstance(data, dict):
|
||||
raise LooperError(f"Registry {path} must contain a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def write_registry(data: dict[str, Any], path: Path = REGISTRY_PATH) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_json(path, data)
|
||||
|
||||
|
||||
def run_probe(argv: list[str], timeout_sec: int = 5) -> tuple[bool, str]:
|
||||
probe_argv = list(argv)
|
||||
if os.name == "nt":
|
||||
resolved = shutil.which(argv[0])
|
||||
if resolved and Path(resolved).suffix.lower() in {".cmd", ".bat"}:
|
||||
probe_argv = ["cmd", "/d", "/c", *argv]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
probe_argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=timeout_sec,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return False, str(exc)
|
||||
|
||||
output = (completed.stdout or completed.stderr or "").strip()
|
||||
return completed.returncode == 0, output.splitlines()[0] if output else ""
|
||||
|
||||
|
||||
def detect_models() -> dict[str, Any]:
|
||||
registry: dict[str, Any] = {}
|
||||
for model_id, meta in MODEL_PROBES.items():
|
||||
cli = meta["invoke"][0]
|
||||
path = shutil.which(cli)
|
||||
available = path is not None
|
||||
authed = False
|
||||
version = ""
|
||||
if available:
|
||||
authed, version = run_probe(meta["probe"])
|
||||
registry[model_id] = {
|
||||
"cli": cli,
|
||||
"path": path,
|
||||
"invoke": meta["invoke"],
|
||||
"available": available,
|
||||
"authed": authed,
|
||||
"local": meta["local"],
|
||||
"probe": meta["probe"],
|
||||
"version": version,
|
||||
"install": meta["install"],
|
||||
"capabilities": meta.get("capabilities", []),
|
||||
}
|
||||
return registry
|
||||
|
||||
|
||||
def normalize_argv(value: Any, field: str) -> list[str]:
|
||||
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return shlex.split(value, posix=os.name != "nt")
|
||||
raise LooperError(f"{field} must be an argv array or string")
|
||||
|
||||
|
||||
def criteria_by_id(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
criteria = spec.get("goal", {}).get("verification", [])
|
||||
if not isinstance(criteria, list):
|
||||
raise LooperError("goal.verification must be a list")
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for item in criteria:
|
||||
if not isinstance(item, dict):
|
||||
raise LooperError("Each verification criterion must be an object")
|
||||
cid = item.get("id")
|
||||
ctype = item.get("type")
|
||||
if not isinstance(cid, str) or not cid:
|
||||
raise LooperError("Each verification criterion needs a non-empty id")
|
||||
if cid in result:
|
||||
raise LooperError(f"Duplicate verification criterion id: {cid}")
|
||||
if ctype not in {"programmatic", "judge", "human"}:
|
||||
raise LooperError(f"Criterion {cid} has invalid type: {ctype}")
|
||||
if ctype == "programmatic":
|
||||
item["check"] = normalize_argv(item.get("check"), f"criterion {cid}.check")
|
||||
if item.get("expect") not in {"exit_zero", "exit_nonzero", "stdout_contains"}:
|
||||
raise LooperError(
|
||||
f"Criterion {cid}.expect must be exit_zero, exit_nonzero, or stdout_contains"
|
||||
)
|
||||
if item.get("expect") == "stdout_contains" and not isinstance(item.get("contains"), str):
|
||||
raise LooperError(f"Criterion {cid} with stdout_contains needs contains")
|
||||
elif ctype == "judge" and not isinstance(item.get("rubric"), str):
|
||||
raise LooperError(f"Criterion {cid} needs a judge rubric")
|
||||
elif ctype == "human" and not isinstance(item.get("prompt"), str):
|
||||
raise LooperError(f"Criterion {cid} needs a human prompt")
|
||||
result[cid] = item
|
||||
return result
|
||||
|
||||
|
||||
def validate_member(member: dict[str, Any]) -> None:
|
||||
mid = member.get("id")
|
||||
role = member.get("role")
|
||||
if not isinstance(mid, str) or not mid:
|
||||
raise LooperError("Each council member needs a non-empty id")
|
||||
if role not in {"reviewer", "judge"}:
|
||||
raise LooperError(f"Council member {mid} role must be reviewer or judge")
|
||||
member["invoke"] = normalize_argv(member.get("invoke"), f"council.{mid}.invoke")
|
||||
timeout = member.get("timeout_sec", 600)
|
||||
if not isinstance(timeout, int) or timeout <= 0:
|
||||
raise LooperError(f"Council member {mid}.timeout_sec must be a positive integer")
|
||||
member.setdefault("scope", ["plan", "delivery"])
|
||||
member.setdefault("local", member.get("cli") == "ollama")
|
||||
|
||||
|
||||
def validate_gate(
|
||||
name: str,
|
||||
gate: dict[str, Any],
|
||||
criteria: dict[str, dict[str, Any]],
|
||||
members: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
if not isinstance(gate, dict):
|
||||
raise LooperError(f"{name} must be an object")
|
||||
policy = gate.get("verdict_policy")
|
||||
if policy not in {"revise_until_clean", "fixed_passes"}:
|
||||
raise LooperError(f"{name}.verdict_policy must be revise_until_clean or fixed_passes")
|
||||
max_revisions = gate.get("max_revisions", 1)
|
||||
if not isinstance(max_revisions, int) or max_revisions < 0:
|
||||
raise LooperError(f"{name}.max_revisions must be a non-negative integer")
|
||||
for cid in gate.get("criteria", []):
|
||||
if cid not in criteria:
|
||||
raise LooperError(f"{name} references unknown criterion: {cid}")
|
||||
for mid in gate.get("members", []):
|
||||
if mid not in members:
|
||||
raise LooperError(f"{name} references unknown council member: {mid}")
|
||||
if policy == "revise_until_clean":
|
||||
source = gate.get("verdict_source")
|
||||
if source == "human":
|
||||
return
|
||||
if source not in members:
|
||||
raise LooperError(f"{name}.verdict_source must be a judge member or human")
|
||||
if members[source].get("role") != "judge":
|
||||
raise LooperError(f"{name}.verdict_source must name a judge, not a reviewer")
|
||||
|
||||
|
||||
def normalize_spec(spec: dict[str, Any], source_path: Path) -> dict[str, Any]:
|
||||
if spec.get("version") != 1:
|
||||
raise LooperError("Only loop.yaml version: 1 is supported")
|
||||
|
||||
goal = spec.get("goal")
|
||||
if not isinstance(goal, dict):
|
||||
raise LooperError("goal must be an object")
|
||||
if not isinstance(goal.get("statement"), str) or not goal["statement"].strip():
|
||||
raise LooperError("goal.statement is required")
|
||||
if not isinstance(goal.get("definition_of_done"), str) or not goal["definition_of_done"].strip():
|
||||
raise LooperError("goal.definition_of_done is required")
|
||||
|
||||
for index, source in enumerate(goal.get("context_sources", [])):
|
||||
if not isinstance(source, dict):
|
||||
raise LooperError("goal.context_sources entries must be objects")
|
||||
if "cmd" in source:
|
||||
source["cmd"] = normalize_argv(source["cmd"], f"context_sources[{index}].cmd")
|
||||
|
||||
criteria = criteria_by_id(spec)
|
||||
|
||||
host = spec.get("host")
|
||||
if not isinstance(host, dict):
|
||||
raise LooperError("host must be an object")
|
||||
host["invoke"] = normalize_argv(host.get("invoke"), "host.invoke")
|
||||
host.setdefault("timeout_sec", 600)
|
||||
if not isinstance(host["timeout_sec"], int) or host["timeout_sec"] <= 0:
|
||||
raise LooperError("host.timeout_sec must be a positive integer")
|
||||
|
||||
council_list = spec.get("council", [])
|
||||
if not isinstance(council_list, list):
|
||||
raise LooperError("council must be a list")
|
||||
for member in council_list:
|
||||
if not isinstance(member, dict):
|
||||
raise LooperError("council entries must be objects")
|
||||
validate_member(member)
|
||||
members = {member["id"]: member for member in council_list}
|
||||
|
||||
gates = spec.get("gates")
|
||||
if not isinstance(gates, dict):
|
||||
raise LooperError("gates must be an object")
|
||||
for gate_name in ("plan_gate", "delivery_gate"):
|
||||
validate_gate(gate_name, gates.get(gate_name), criteria, members)
|
||||
|
||||
control = spec.get("loop_control")
|
||||
if not isinstance(control, dict):
|
||||
raise LooperError("loop_control must be an object")
|
||||
max_iterations = control.get("max_iterations")
|
||||
if not isinstance(max_iterations, int) or max_iterations <= 0:
|
||||
raise LooperError("loop_control.max_iterations must be a positive integer")
|
||||
budget = control.setdefault("budget", {})
|
||||
if not isinstance(budget, dict):
|
||||
raise LooperError("loop_control.budget must be an object")
|
||||
if "wall_clock_min" not in budget:
|
||||
budget["wall_clock_min"] = 30
|
||||
no_progress = control.setdefault(
|
||||
"no_progress",
|
||||
{
|
||||
"max_stalled_iterations": 2,
|
||||
"signals": [
|
||||
"same blocking issue repeats",
|
||||
"delivery artifact has no material change",
|
||||
"verifier output is unchanged",
|
||||
],
|
||||
"action": "stop",
|
||||
},
|
||||
)
|
||||
if not isinstance(no_progress, dict):
|
||||
raise LooperError("loop_control.no_progress must be an object")
|
||||
stalled = no_progress.setdefault("max_stalled_iterations", 2)
|
||||
if not isinstance(stalled, int) or stalled <= 0:
|
||||
raise LooperError("loop_control.no_progress.max_stalled_iterations must be a positive integer")
|
||||
signals = no_progress.setdefault("signals", ["same blocking issue repeats"])
|
||||
if not isinstance(signals, list) or not all(isinstance(item, str) for item in signals):
|
||||
raise LooperError("loop_control.no_progress.signals must be a list of strings")
|
||||
action = no_progress.setdefault("action", "stop")
|
||||
if action not in {"stop", "human_checkpoint"}:
|
||||
raise LooperError("loop_control.no_progress.action must be stop or human_checkpoint")
|
||||
|
||||
execution = spec.setdefault(
|
||||
"execution",
|
||||
{
|
||||
"mode": "in_session",
|
||||
"isolation": "current_workspace",
|
||||
"side_effects": {"requires_approval": True, "duplicate_action_check": True},
|
||||
},
|
||||
)
|
||||
if not isinstance(execution, dict):
|
||||
raise LooperError("execution must be an object")
|
||||
execution.setdefault("mode", "in_session")
|
||||
execution.setdefault("isolation", "current_workspace")
|
||||
if execution["mode"] not in {"in_session", "external_runner", "orchestrated"}:
|
||||
raise LooperError("execution.mode must be in_session, external_runner, or orchestrated")
|
||||
if execution["isolation"] not in {"current_workspace", "branch", "worktree", "sandbox"}:
|
||||
raise LooperError("execution.isolation must be current_workspace, branch, worktree, or sandbox")
|
||||
side_effects = execution.setdefault("side_effects", {})
|
||||
if not isinstance(side_effects, dict):
|
||||
raise LooperError("execution.side_effects must be an object")
|
||||
side_effects.setdefault("requires_approval", True)
|
||||
side_effects.setdefault("duplicate_action_check", True)
|
||||
|
||||
observability = spec.setdefault(
|
||||
"observability",
|
||||
{"state_file": "state.json", "run_log": "run-log.md", "checkpoint_granularity": "gate"},
|
||||
)
|
||||
if not isinstance(observability, dict):
|
||||
raise LooperError("observability must be an object")
|
||||
observability.setdefault("state_file", "state.json")
|
||||
observability.setdefault("run_log", "run-log.md")
|
||||
observability.setdefault("checkpoint_granularity", "gate")
|
||||
if not isinstance(observability["state_file"], str) or not observability["state_file"]:
|
||||
raise LooperError("observability.state_file must be a non-empty string")
|
||||
if not isinstance(observability["run_log"], str) or not observability["run_log"]:
|
||||
raise LooperError("observability.run_log must be a non-empty string")
|
||||
if observability["checkpoint_granularity"] not in {"gate", "step"}:
|
||||
raise LooperError("observability.checkpoint_granularity must be gate or step")
|
||||
|
||||
workspace = spec.setdefault("workspace", {})
|
||||
if not isinstance(workspace, dict):
|
||||
raise LooperError("workspace must be an object")
|
||||
workspace.setdefault("dir", "./loop-workspace")
|
||||
layout = workspace.setdefault("layout", ["plan.md", "delivery-{n}.md", "review-{n}.md", "state.json", "run-log.md"])
|
||||
if not isinstance(layout, list) or not all(isinstance(item, str) for item in layout):
|
||||
raise LooperError("workspace.layout must be a list of strings")
|
||||
for required_file in (observability["state_file"], observability["run_log"]):
|
||||
if required_file not in layout:
|
||||
layout.append(required_file)
|
||||
|
||||
privacy = spec.setdefault("privacy", {})
|
||||
if not isinstance(privacy, dict):
|
||||
raise LooperError("privacy must be an object")
|
||||
egress = privacy.setdefault("egress", [])
|
||||
if not isinstance(egress, list):
|
||||
raise LooperError("privacy.egress must be a list")
|
||||
for entry in egress:
|
||||
if not isinstance(entry, dict):
|
||||
raise LooperError("privacy.egress entries must be objects")
|
||||
entry.setdefault("redact", DEFAULT_REDACTIONS)
|
||||
entry.setdefault("consent", "required")
|
||||
|
||||
resolved = {
|
||||
"$schema": "https://github.com/ksimback/looper/schema/loop.resolved.v1.json",
|
||||
"compiled_at": _dt.datetime.now(_dt.UTC).replace(microsecond=0).isoformat(),
|
||||
"source": str(source_path),
|
||||
**spec,
|
||||
"criteria_by_id": criteria,
|
||||
"council_by_id": members,
|
||||
}
|
||||
return to_jsonable(resolved)
|
||||
|
||||
|
||||
def clip(text: Any, width: int) -> str:
|
||||
value = str(text or "")
|
||||
return value if len(value) <= width else value[: width - 1] + "~"
|
||||
|
||||
|
||||
def ascii_box(*rows: str, width: int = 30) -> list[str]:
|
||||
border = "+" + "-" * (width + 2) + "+"
|
||||
body = [f"| {clip(row, width):<{width}} |" for row in rows if row is not None]
|
||||
return [border, *body, border]
|
||||
|
||||
|
||||
def render_ascii_diagram(resolved: dict[str, Any]) -> str:
|
||||
gates = resolved.get("gates", {})
|
||||
control = resolved.get("loop_control", {})
|
||||
observability = resolved.get("observability", {})
|
||||
plan_gate = gates.get("plan_gate", {})
|
||||
delivery_gate = gates.get("delivery_gate", {})
|
||||
plan_revisions = plan_gate.get("max_revisions", 0)
|
||||
delivery_revisions = delivery_gate.get("max_revisions", 0)
|
||||
plan_source = plan_gate.get("verdict_source", "human")
|
||||
delivery_source = delivery_gate.get("verdict_source", "human")
|
||||
no_progress = control.get("no_progress", {})
|
||||
stalled = no_progress.get("max_stalled_iterations", 2)
|
||||
budget = control.get("budget", {})
|
||||
budget_bits = []
|
||||
if budget.get("wall_clock_min") is not None:
|
||||
budget_bits.append(f"{budget.get('wall_clock_min')}m")
|
||||
if budget.get("usd") is not None:
|
||||
budget_bits.append(f"${budget.get('usd')}")
|
||||
if budget.get("tokens") is not None:
|
||||
budget_bits.append(f"{budget.get('tokens')} tokens")
|
||||
budget_text = ", ".join(budget_bits) or "configured caps"
|
||||
|
||||
lines: list[str] = []
|
||||
lines.extend(ascii_box("1. Goal + context", "read sources"))
|
||||
lines.extend([" |", " v"])
|
||||
lines.extend(ascii_box("2. Draft plan.md", f"state -> {observability.get('state_file', 'state.json')}"))
|
||||
lines.extend([" |", " v"])
|
||||
lines.extend(ascii_box("3. Plan gate", f"verdict: {plan_source}"))
|
||||
lines.extend([f" | needs work -> revise <= {plan_revisions} -> step 2", " | pass", " v"])
|
||||
lines.extend(ascii_box("4. Write delivery-N.md", f"log -> {observability.get('run_log', 'run-log.md')}"))
|
||||
lines.extend([" |", " v"])
|
||||
lines.extend(ascii_box("5. Delivery gate", f"verdict: {delivery_source}"))
|
||||
lines.extend([f" | needs work -> revise <= {delivery_revisions} -> step 4", " | pass", " v"])
|
||||
lines.extend(ascii_box("6. Final output", "all gates clean"))
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"Stops: pass gates | max {control.get('max_iterations')} iterations | "
|
||||
f"no progress x{stalled} | budget {budget_text}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_loop(resolved: dict[str, Any]) -> str:
|
||||
meta = resolved.get("meta", {})
|
||||
goal = resolved.get("goal", {})
|
||||
gates = resolved.get("gates", {})
|
||||
control = resolved.get("loop_control", {})
|
||||
execution = resolved.get("execution", {})
|
||||
observability = resolved.get("observability", {})
|
||||
title = meta.get("name") or "Looper Generated Loop"
|
||||
criteria = goal.get("verification", [])
|
||||
council = resolved.get("council", [])
|
||||
|
||||
lines = [
|
||||
f"# {title}",
|
||||
"",
|
||||
meta.get("description", "").strip(),
|
||||
"",
|
||||
"## Goal",
|
||||
"",
|
||||
goal.get("statement", "").strip(),
|
||||
"",
|
||||
"## Definition of Done",
|
||||
"",
|
||||
goal.get("definition_of_done", "").strip(),
|
||||
"",
|
||||
"## Verification",
|
||||
"",
|
||||
]
|
||||
for item in criteria:
|
||||
lines.append(f"- `{item['id']}` ({item['type']})")
|
||||
lines.extend(["", "## Council", ""])
|
||||
if council:
|
||||
for member in council:
|
||||
lines.append(
|
||||
f"- `{member['id']}`: {member.get('role')} via {member.get('cli')} "
|
||||
f"({member.get('model', 'default')})"
|
||||
)
|
||||
else:
|
||||
lines.append("- No council members configured.")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Gates",
|
||||
"",
|
||||
f"- Plan gate: {gates.get('plan_gate', {}).get('verdict_policy')}",
|
||||
f"- Delivery gate: {gates.get('delivery_gate', {}).get('verdict_policy')}",
|
||||
"",
|
||||
"## Loop Control",
|
||||
"",
|
||||
f"- Max iterations: {control.get('max_iterations')}",
|
||||
f"- Budget: `{json.dumps(control.get('budget', {}), sort_keys=True)}`",
|
||||
f"- No-progress: `{json.dumps(control.get('no_progress', {}), sort_keys=True)}`",
|
||||
"",
|
||||
"## Execution Boundary",
|
||||
"",
|
||||
f"- Mode: `{execution.get('mode', 'in_session')}`",
|
||||
f"- Isolation: `{execution.get('isolation', 'current_workspace')}`",
|
||||
f"- Side effects: `{json.dumps(execution.get('side_effects', {}), sort_keys=True)}`",
|
||||
"",
|
||||
"## Observability",
|
||||
"",
|
||||
f"- State file: `{observability.get('state_file', 'state.json')}`",
|
||||
f"- Run log: `{observability.get('run_log', 'run-log.md')}`",
|
||||
f"- Checkpoint granularity: `{observability.get('checkpoint_granularity', 'gate')}`",
|
||||
"",
|
||||
"## Flow Preview",
|
||||
"",
|
||||
"```text",
|
||||
render_ascii_diagram(resolved),
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(line for line in lines if line is not None)
|
||||
|
||||
|
||||
def render_session_prompt(resolved: dict[str, Any]) -> str:
|
||||
meta = resolved.get("meta", {})
|
||||
goal = resolved.get("goal", {})
|
||||
gates = resolved.get("gates", {})
|
||||
control = resolved.get("loop_control", {})
|
||||
workspace = resolved.get("workspace", {})
|
||||
execution = resolved.get("execution", {})
|
||||
observability = resolved.get("observability", {})
|
||||
criteria = goal.get("verification", [])
|
||||
council = resolved.get("council", [])
|
||||
title = meta.get("name") or "Looper Generated Loop"
|
||||
|
||||
lines = [
|
||||
f"# Run `{title}` In This Session",
|
||||
"",
|
||||
"Use this prompt when the user wants to run the Looper-designed loop in the current LLM session.",
|
||||
"This is the default/easy execution path. The Python runner is the advanced path for running later or outside the session.",
|
||||
"",
|
||||
"## Operator Instructions",
|
||||
"",
|
||||
"You are executing a Looper-designed loop in this current session.",
|
||||
"Follow the resolved spec below, write handoff files into the workspace, and enforce the caps manually.",
|
||||
"Do not use `run-loop.py` unless the user explicitly asks for the advanced external runner.",
|
||||
"",
|
||||
"1. Create the workspace directory if it does not exist.",
|
||||
"2. Read the context sources before drafting the plan.",
|
||||
"3. Draft `plan.md` in the workspace.",
|
||||
"4. Run the plan gate. Apply programmatic checks when available. For judge criteria, use the configured judge only after consent for any non-local egress; otherwise ask the user to approve a human/current-session substitute.",
|
||||
"5. Revise until the gate passes or `max_revisions` is reached.",
|
||||
"6. Produce `delivery-N.md` in the workspace.",
|
||||
"7. Run the delivery gate after each delivery.",
|
||||
"8. Stop when all delivery criteria pass, a cap is reached, or the user stops the loop.",
|
||||
"9. Keep `state.json` current with status, iteration, last gate, consent, and blockers.",
|
||||
"10. Append a compact entry to `run-log.md` after every context read, model call, check, gate verdict, revision, blocker, and stop decision.",
|
||||
"11. Compare each blocker against the previous blocker. If the same blocker repeats for the configured no-progress window, stop or ask for the configured human checkpoint instead of revising again.",
|
||||
"12. Treat token and USD budgets as operator limits in this session: if exact accounting is unavailable, stop and ask before continuing when the loop appears likely to exceed them.",
|
||||
"",
|
||||
"## Files",
|
||||
"",
|
||||
f"- Source spec: `{Path(resolved.get('source', 'loop.yaml')).name}`",
|
||||
"- Human summary: `LOOP.md`",
|
||||
"- Resolved spec: `loop.resolved.json`",
|
||||
f"- Workspace: `{workspace.get('dir', './loop-workspace')}`",
|
||||
f"- State file: `{observability.get('state_file', 'state.json')}`",
|
||||
f"- Run log: `{observability.get('run_log', 'run-log.md')}`",
|
||||
"",
|
||||
"## Goal",
|
||||
"",
|
||||
goal.get("statement", "").strip(),
|
||||
"",
|
||||
"## Definition Of Done",
|
||||
"",
|
||||
goal.get("definition_of_done", "").strip(),
|
||||
"",
|
||||
"## Context Sources",
|
||||
"",
|
||||
]
|
||||
|
||||
context_sources = goal.get("context_sources", [])
|
||||
if context_sources:
|
||||
for source in context_sources:
|
||||
if "file" in source:
|
||||
lines.append(f"- Read file `{source['file']}`")
|
||||
elif "cmd" in source:
|
||||
lines.append(f"- Run command `{json.dumps(source['cmd'])}`")
|
||||
else:
|
||||
lines.append("- No context sources configured.")
|
||||
|
||||
lines.extend(["", "## Verification Criteria", ""])
|
||||
for item in criteria:
|
||||
if item["type"] == "programmatic":
|
||||
lines.append(
|
||||
f"- `{item['id']}` programmatic: run `{json.dumps(item['check'])}` and expect `{item['expect']}`"
|
||||
)
|
||||
elif item["type"] == "judge":
|
||||
lines.append(f"- `{item['id']}` judge rubric: {item['rubric']}")
|
||||
elif item["type"] == "human":
|
||||
lines.append(f"- `{item['id']}` human signoff: {item['prompt']}")
|
||||
|
||||
lines.extend(["", "## Council", ""])
|
||||
if council:
|
||||
for member in council:
|
||||
locality = "local" if member.get("local") else "non-local"
|
||||
lines.append(
|
||||
f"- `{member['id']}` {member.get('role')} via `{json.dumps(member.get('invoke', []))}` "
|
||||
f"({locality}; timeout {member.get('timeout_sec', 600)}s)"
|
||||
)
|
||||
else:
|
||||
lines.append("- No council members configured.")
|
||||
|
||||
lines.extend(["", "## Gates", ""])
|
||||
for gate_name in ("plan_gate", "delivery_gate"):
|
||||
gate = gates.get(gate_name, {})
|
||||
lines.extend(
|
||||
[
|
||||
f"### {gate_name}",
|
||||
"",
|
||||
f"- When: `{gate.get('when')}`",
|
||||
f"- Policy: `{gate.get('verdict_policy')}`",
|
||||
f"- Verdict source: `{gate.get('verdict_source', 'none')}`",
|
||||
f"- Criteria: `{', '.join(gate.get('criteria', []))}`",
|
||||
f"- Max revisions: `{gate.get('max_revisions')}`",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"## Loop Control",
|
||||
"",
|
||||
f"- Max iterations: `{control.get('max_iterations')}`",
|
||||
f"- Budget: `{json.dumps(control.get('budget', {}), sort_keys=True)}`",
|
||||
f"- No-progress: `{json.dumps(control.get('no_progress', {}), sort_keys=True)}`",
|
||||
f"- Human checkpoints: `{', '.join(control.get('human_checkpoints', [])) or 'none'}`",
|
||||
"- Stop conditions:",
|
||||
]
|
||||
)
|
||||
for condition in control.get("stop_conditions", []):
|
||||
lines.append(f" - {condition}")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Execution Boundary",
|
||||
"",
|
||||
f"- Mode: `{execution.get('mode', 'in_session')}`",
|
||||
f"- Isolation: `{execution.get('isolation', 'current_workspace')}`",
|
||||
f"- Side effects: `{json.dumps(execution.get('side_effects', {}), sort_keys=True)}`",
|
||||
"",
|
||||
"If the loop needs scheduled runs, child-agent lifecycle management, concurrency control, or restart-safe step retries, stop and tell the user this Looper spec should be handed to a durable orchestrator.",
|
||||
"",
|
||||
"## Observability",
|
||||
"",
|
||||
f"- State file: `{observability.get('state_file', 'state.json')}`",
|
||||
f"- Run log: `{observability.get('run_log', 'run-log.md')}`",
|
||||
f"- Checkpoint granularity: `{observability.get('checkpoint_granularity', 'gate')}`",
|
||||
"",
|
||||
"Use `state.json` for the latest resumable status and `run-log.md` for the append-only history of what happened.",
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(["", "## Privacy", ""])
|
||||
egress = resolved.get("privacy", {}).get("egress", [])
|
||||
if egress:
|
||||
for entry in egress:
|
||||
lines.append(
|
||||
f"- Before sending `{', '.join(entry.get('sends', []))}` to `{entry.get('to')}`, "
|
||||
f"confirm consent and apply redactions `{', '.join(entry.get('redact', []))}`."
|
||||
)
|
||||
else:
|
||||
lines.append("- No cross-vendor egress configured.")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Start Now",
|
||||
"",
|
||||
"If the user asked to run now, begin at step 1 under Operator Instructions and keep going until a stop condition is reached.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def cmd_detect(args: argparse.Namespace) -> int:
|
||||
registry = detect_models()
|
||||
if args.write:
|
||||
existing = read_registry(args.registry)
|
||||
existing.update(registry)
|
||||
write_registry(existing, args.registry)
|
||||
print(json.dumps(registry, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_register(args: argparse.Namespace) -> int:
|
||||
if not args.invoke:
|
||||
raise LooperError("--invoke needs at least one command token")
|
||||
registry = read_registry(args.registry)
|
||||
registry[args.model_id] = {
|
||||
"cli": args.invoke[0],
|
||||
"invoke": args.invoke,
|
||||
"available": shutil.which(args.invoke[0]) is not None,
|
||||
"authed": args.authed,
|
||||
"local": args.local,
|
||||
"model": args.model,
|
||||
"notes": args.notes or "",
|
||||
}
|
||||
write_registry(registry, args.registry)
|
||||
print(f"Registered {args.model_id} in {args.registry}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_compile(args: argparse.Namespace) -> int:
|
||||
source = args.loop_yaml.resolve()
|
||||
spec = load_yaml(source)
|
||||
resolved = normalize_spec(spec, source)
|
||||
out = args.out or source.with_name("loop.resolved.json")
|
||||
write_json(out, resolved)
|
||||
if args.render:
|
||||
args.render.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.render.write_text(render_loop(resolved), encoding="utf-8")
|
||||
if args.session_prompt:
|
||||
args.session_prompt.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.session_prompt.write_text(render_session_prompt(resolved), encoding="utf-8")
|
||||
print(f"Wrote {out}")
|
||||
if args.render:
|
||||
print(f"Wrote {args.render}")
|
||||
if args.session_prompt:
|
||||
print(f"Wrote {args.session_prompt}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_session_prompt(args: argparse.Namespace) -> int:
|
||||
resolved = load_json(args.resolved_json)
|
||||
prompt = render_session_prompt(resolved)
|
||||
if args.out:
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text(prompt, encoding="utf-8")
|
||||
print(f"Wrote {args.out}")
|
||||
else:
|
||||
print(prompt)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="looper", description="Looper scaffolding helpers")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
detect = sub.add_parser("detect-models", help="Detect model CLIs and print registry JSON")
|
||||
detect.add_argument("--write", action="store_true", help="Merge results into the model registry")
|
||||
detect.add_argument("--registry", type=Path, default=REGISTRY_PATH)
|
||||
detect.set_defaults(func=cmd_detect)
|
||||
|
||||
register = sub.add_parser("register-model", help="Register custom model CLI invocation metadata")
|
||||
register.add_argument("model_id")
|
||||
register.add_argument("--invoke", nargs="+", required=True)
|
||||
register.add_argument("--model", default="")
|
||||
register.add_argument("--local", action="store_true")
|
||||
register.add_argument("--authed", action="store_true")
|
||||
register.add_argument("--notes", default="")
|
||||
register.add_argument("--registry", type=Path, default=REGISTRY_PATH)
|
||||
register.set_defaults(func=cmd_register)
|
||||
|
||||
compile_cmd = sub.add_parser("compile", help="Compile loop.yaml to loop.resolved.json")
|
||||
compile_cmd.add_argument("loop_yaml", type=Path)
|
||||
compile_cmd.add_argument("--out", type=Path)
|
||||
compile_cmd.add_argument("--render", type=Path)
|
||||
compile_cmd.add_argument("--session-prompt", type=Path)
|
||||
compile_cmd.set_defaults(func=cmd_compile)
|
||||
|
||||
session_prompt = sub.add_parser(
|
||||
"session-prompt", help="Render the in-session execution prompt from loop.resolved.json"
|
||||
)
|
||||
session_prompt.add_argument("resolved_json", type=Path)
|
||||
session_prompt.add_argument("--out", type=Path)
|
||||
session_prompt.set_defaults(func=cmd_session_prompt)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
return int(args.func(args))
|
||||
except LooperError as exc:
|
||||
print(f"looper: error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user