looper设计型 loop
§ 00 · Source-level Observatory

looper 设计型 agent loop

一个 Claude Code /looper skill:在跑 loop 之前先和用户做 7 轮访谈,把 goal、verification、host、council、gates 拷问成 YAML,再编译成 loop.resolved.json,交给可执行的 run-loop.py 或当前 session 的 RUN_IN_SESSION.md。

★ 561
GitHub Stars
2.1K
LOC
12
核心文件
2026-06-24
Last commit
flowchart LR User([User intent]) subgraph Scaffold["Looper /looper wizard"] S1[Goal + context] S2[Verification criteria] S3[Host + council models] S4[Loop control + gates] S5[Execution + privacy] S6[Emit artifacts] S1 --> S2 --> S3 --> S4 --> S5 --> S6 end Y["loop.yaml"]:::data R["loop.resolved.json"]:::data L["LOOP.md"]:::data P["RUN_IN_SESSION.md"]:::data R1["run-loop.py"]:::data User --> Scaffold Scaffold --> Y Y -- compile --> R R -- render --> L R -- render --> P R -- read --> R1 subgraph Run["Runner.run loop"] G["gather_context"]:::node H["run_host plan"]:::node PG["plan_gate"]:::node Loop{{"iterate i G --> H --> PG --> Loop Loop -- iterate --> HD --> DG DG -- pass --> Stop1 DG -- revise_until_clean --> HD DG -- failures x N --> NoP NoP --> Stop3 DG -- budget --> Stop2 P -. in-session .-> H classDef data fill:#1a1c22,stroke:#a17fe0,color:#b89bf0; classDef node fill:#1a1c22,stroke:#54d6e0,color:#d6f6fb; classDef stop fill:#1a1c22,stroke:#e08a64,color:#f0a585;
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

Claude Code 自带的 /goal 让模型自己盯目标,/loop 是间隔调度器——二者都没强迫你把"什么是完成" (authored 为 sources/ksimback-looper/schemas/loop.v1.schema.json:18)写成可检验的形式,也没让你请第二个模型看一眼。 Looper 正好卡在这个 gap 上:scripts/looper.py sources/ksimback-looper/scripts/looper.py:1 是一个 scaffolder,自身不会调用任何模型,只读 YAML、跑验证、写文件;真正跑 loop 的是被它 emit 出去的 templates/run-loop.py sources/ksimback-looper/templates/run-loop.py:1(外部 runner)或 RUN_IN_SESSION.md sources/ksimback-looper/SKILL.md sources/ksimback-looper/SKILL.md:1:79(当前 session prompt)。

从源码看,Looper 把"好 loop"拆成四件必需品:typed verification(programmatic / judge / human 三类)、可替换 council(reviewer 写笔记、judge 出 verdict)、三重终止守卫(max_iterations + gate.max_revisions + no_progress 签名)、跨 vendor 隐私边界(argv 不入 shell、转译前先 redact .env + secrets + .key)。任何一项缺失都会被 normalize_spec() sources/ksimback-looper/scripts/looper.py:254 当 LooperError sources/ksimback-looper/scripts/looper.py:60 抛出。

▸核心数据

架构模式
Scaffolder + resolved-spec 单一可信源:coach 写 YAML,runner 只读 JSON;两套 exit path 共享同一个 loop.resolved.json
核心抽象
gate = {plan_gate, delivery_gate} + verdict_source + max_revisions;council = reviewer (notes) ≠ judge (verdict)
终止守卫
三层保险:max_iterations、每个 gate 的 max_revisions、no_progress.signature 连续 N 次
下游 & 测试
依赖 PyYAML (sources/ksimback-looper/scripts/looper.py:66 在 load_yaml 强制导入) + stdlib;测试 fixtures 用 fake_host.py / fake_judge.py 模拟 host 与 judge,不依赖真实 CLI
本文导航 — 6 个章节依次:概述 → 三段式架构 → 核心四件套 → compile→run 流程 → 终止守卫与隐私 → 亮点 + 风险。子页面在 §02、§03、§04 末尾以 深度阅读 → 入口展开。
§ 01 · ARCHITECTURE

三段式拓扑:coach / spec / runner

Looper 把"好 loop" 当成一份配置而不是一段 runtime。三段分别对应三组文件,严格单向数据流:/looper 写 loop.yaml → scripts/looper.py compile 把它压成 loop.resolved.json → 外部用 templates/run-loop.py 跑,或回到当前 session 按 RUN_IN_SESSION.md 跑。两个 runner 共享同一份 resolved spec,因此设计期做的小改动可以无损地换 surface。

▸系统拓扑

[Coach · /looper] Claude Code skill ↓ loop.yaml (authoring) ↓ [Compile · scripts/looper.py] validate + normalize + argv-expand ↓ loop.resolved.json ↓ ┌───────────────┴───────────────┐ ↓ ↓ [Render] [External Runner] LOOP.md + RUN_IN_SESSION.md run-loop.py ↓ pasted into current session (stdlib only, reads JSON) Runner.run logic ↓ host → plan_gate → loop≤N state.json + run-log.md

▸模块清单

三段对应的 12 个源码文件 (主入口 sources/ksimback-looper/scripts/looper.py:1,运行端 sources/ksimback-looper/templates/run-loop.py:1),所有运行时都站在 resolved.json 一侧,YAML 只在设计期触碰。

scripts/looper.py · 805 行
Coach 端。detect_models / compile / register-model / session-prompt 4 个子命令;MODEL_PROBES 内置 5 个 CLI;normalize_spec 把 YAML 折叠成可执行 JSON。
templates/run-loop.py · 588 行
Runner 端。stdlib only,子进程调 host/council;Runner.run 是入口,run_gate 是 with-revision 的裁判循环,no_progress_reached 是签名式终止。
schemas/loop.v1.schema.json
JSON Schema draft 2020-12。规范 gating / verification / council 的合法形状。resolved schema 是同一份去掉了 $defs 的扁平版本。
references/*.md
5 份 rubric 文档 (sources/ksimback-looper/references/control-rubric.md 等)——coach 在不同 stage 按需加载,避免一次性塞进上下文。
templates/loop.yaml
作者模板。注释里要求 argv 数组、跨 vendor 默认 redact globs、loop_control 必须有 max_iterations + budget + no_progress。
sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:37 examples/ai-workflow-mapping/
唯一示例。演示 host 是 codex exec,judge 是 claude -p,2 gates 都用 revise_until_clean,配 USD + tokens + wall_clock 预算。

▸关键数据结构 — MODEL_PROBES

codex / claude / gemini / llm / ollama (sources/ksimback-looper/scripts/looper.py:27-56) 五个 CLI 的探测表是 detect_models 的真正秘密。它把"我能调用哪个模型"问成一个简单的 --version probe (`scripts/looper.py:148-154` 通过 `run_probe()` sources/ksimback-looper/scripts/looper.py:122),而把 invocation 形状保留在 dict 里供 normalize_spec 直接读。schema 是开放式的,register-model sources/ksimback-looper/scripts/looper.py:708 子命令可追加新 CLI。

scripts/looper.py · L23-61Python
  23DEFAULT_REDACTIONS = [".env", ".env.*", "secrets/**", "**/*.key"]
  24REGISTRY_PATH = Path.home() / ".looper" / "models.json"
  25
  26MODEL_PROBES: dict[str, dict[str, Any]] = {
  27    "claude": {
  28        "invoke": ["claude", "-p"],
  29        "probe": ["claude", "--version"],
  30        "local": False,
  31        "install": "Install and authenticate the Claude CLI.",
  32    },
  33    "codex": {
  34        "invoke": ["codex", "exec"],
  35        "probe": ["codex", "--version"],
  36        "local": False,
  37        "install": "Install and authenticate the Codex CLI.",
  38    },
  39    "gemini": {
  40        "invoke": ["gemini", "-p"],
  41        "probe": ["gemini", "--version"],
  42        "local": False,
  43        "install": "Install and authenticate the Gemini CLI.",
  44    },
  45    "llm": {
  46        "invoke": ["llm"],
  47        "probe": ["llm", "--version"],
  48        "local": False,
  49        "install": "Install llm and configure a model/provider.",
  50    },
  51    "ollama": {
  52        "invoke": ["ollama", "run"],
  53        "probe": ["ollama", "--version"],
  54        "local": True,
  55        "install": "Install Ollama and pull a local model.",
  56    },
  57}
  58
  59
  60class LooperError(RuntimeError):
  61    pass
深度阅读 · YAML → resolved.json 的 normalize_spec 流程 →
§ 02 · CORE MODULES

核心抽象:goal + gates + council

▸detect_models — 把"装了什么 CLI" 探成 registry,登记进 ~/.looper/models.json sources/ksimback-looper/scripts/looper.py:24

codex / claude / gemini / llm / ollama 各自有 invoke / probe / local / install 四元组。looper 用 shutil.which() 找二进制,再用 run_probe() 调版本命令并截取第一行输出,封装成 JSON 存进 ~/.looper/models.json,normalize_spec 之后用 spec["host"]["invoke"] / council[*].invoke 直接消费 (各 council member 在 sources/ksimback-looper/scripts/looper.py:282 校验)。

scripts/looper.py · L145-166Python
 145def detect_models() -> dict[str, Any]:
 146    registry: dict[str, Any] = {}
 147    for model_id, meta in MODEL_PROBES.items():
 148        cli = meta["invoke"][0]
 149        path = shutil.which(cli)
 150        available = path is not None
 151        authed = False
 152        version = ""
 153        if available:
 154            authed, version = run_probe(meta["probe"])
 155        registry[model_id] = {
 156            "cli": cli,
 157            "path": path,
 158            "invoke": meta["invoke"],
 159            "available": available,
 160            "authed": authed,
 161            "local": meta["local"],
 162            "probe": meta["probe"],
 163            "version": version,
 164            "install": meta["install"],
 165        }
 166    return registry

▸validate_gate — gate 三段约束

每个 gate 必须是 {plan_gate, delivery_gate} 之一;verdict_policy 只允许 revise_until_clean 或 fixed_passes sources/ksimback-looper/scripts/looper.py:233 第二种策略;criteria 必须在 criteria_by_id 已注册的判定之列;members 必须在 council 里。revise_until_clean 还要 verdict_source 是 judge 角色或 human——这条强制区分了 reviewer 与 judge,否则下次循环会让 structured verdict 解析成 unparseable_judge_output。

scripts/looper.py · L224-251Python
 224def validate_gate(
 225    name: str,
 226    gate: dict[str, Any],
 227    criteria: dict[str, dict[str, Any]],
 228    members: dict[str, dict[str, Any]],
 229) -> None:
 230    if not isinstance(gate, dict):
 231        raise LooperError(f"{name} must be an object")
 232    policy = gate.get("verdict_policy")
 233    if policy not in {"revise_until_clean", "fixed_passes"}:
 234        raise LooperError(f"{name}.verdict_policy must be revise_until_clean or fixed_passes")
 235    max_revisions = gate.get("max_revisions", 1)
 236    if not isinstance(max_revisions, int) or max_revisions < 0:
 237        raise LooperError(f"{name}.max_revisions must be a non-negative integer")
 238    for cid in gate.get("criteria", []):
 239        if cid not in criteria:
 240            raise LooperError(f"{name} references unknown criterion: {cid}")
 241    for mid in gate.get("members", []):
 242        if mid not in members:
 243            raise LooperError(f"{name} references unknown council member: {mid}")
 244    if policy == "revise_until_clean":
 245        source = gate.get("verdict_source")
 246        if source == "human":
 247            return
 248        if source not in members:
 249            raise LooperError(f"{name}.verdict_source must be a judge member or human")
 250        if members[source].get("role") != "judge":
 251            raise LooperError(f"{name}.verdict_source must name a judge, not a reviewer")

▸normalize_spec — defaults / invariants 一次性补齐

这一函数是 scaffolder 的灵魂。它对每个 key 做 if not isinstance(...): LooperError(...),然后用大量 setdefault() 注入默认 (loop_control.budget 默认 30 分钟 sources/ksimback-looper/scripts/looper.py:307);execution 设 mode=in_session / isolation=current_workspace / side_effects 双 true;observability 设 state_file=state.json + run_log=run-log.md + checkpoint_granularity=gate;privacy 默认 4 条 redact glob。spec 于是成为"可以安全地传给 runner 的纯数据"。

scripts/looper.py · L254-290Python
 254def normalize_spec(spec: dict[str, Any], source_path: Path) -> dict[str, Any]:
 255    if spec.get("version") != 1:
 256        raise LooperError("Only loop.yaml version: 1 is supported")
 257
 258    goal = spec.get("goal")
 259    if not isinstance(goal, dict):
 260        raise LooperError("goal must be an object")
 261    if not isinstance(goal.get("statement"), str) or not goal["statement"].strip():
 262        raise LooperError("goal.statement is required")
 263    if not isinstance(goal.get("definition_of_done"), str) or not goal["definition_of_done"].strip():
 264        raise LooperError("goal.definition_of_done is required")
 265
 266    for index, source in enumerate(goal.get("context_sources", [])):
 267        if not isinstance(source, dict):
 268            raise LooperError("goal.context_sources entries must be objects")
 269        if "cmd" in source:
 270            source["cmd"] = normalize_argv(source["cmd"], f"context_sources[{index}].cmd")
 271
 272    criteria = criteria_by_id(spec)
 273
 274    host = spec.get("host")
 275    if not isinstance(host, dict):
 276        raise LooperError("host must be an object")
 277    host["invoke"] = normalize_argv(host.get("invoke"), "host.invoke")
 278    host.setdefault("timeout_sec", 600)
 279    if not isinstance(host["timeout_sec"], int) or host["timeout_sec"] <= 0:
 280        raise LooperError("host.timeout_sec must be a positive integer")
 281
 282    council_list = spec.get("council", [])
 283    if not isinstance(council_list, list):
 284        raise LooperError("council must be a list")
 285    for member in council_list:
 286        if not isinstance(member, dict):
 287            raise LooperError("council entries must be objects")
 288        validate_member(member)
 289    members = {member["id"]: member for member in council_list}
 290

▸render_ascii_diagram — flow preview 编译器

跑 loop 之前先给用户一段 ASCII flow,让 ta 看到"如果 budget=$5 会发生什么 / 哪些 gate 会让 host 改稿"。ascii_box() sources/ksimback-looper/scripts/looper.py:409 切出 +---+ 边框,render_ascii_diagram() 用 6 步固定脚手架:goal+context → plan → plan_gate → delivery → delivery_gate → final;每步的 verdict_source、max_revisions、wall_clock/USD/tokens 都从已 normalize 的 spec 读,输出直接进 LOOP.md 与 RUN_IN_SESSION.md。

scripts/looper.py · L415-456Python
 415def render_ascii_diagram(resolved: dict[str, Any]) -> str:
 416    gates = resolved.get("gates", {})
 417    control = resolved.get("loop_control", {})
 418    observability = resolved.get("observability", {})
 419    plan_gate = gates.get("plan_gate", {})
 420    delivery_gate = gates.get("delivery_gate", {})
 421    plan_revisions = plan_gate.get("max_revisions", 0)
 422    delivery_revisions = delivery_gate.get("max_revisions", 0)
 423    plan_source = plan_gate.get("verdict_source", "human")
 424    delivery_source = delivery_gate.get("verdict_source", "human")
 425    no_progress = control.get("no_progress", {})
 426    stalled = no_progress.get("max_stalled_iterations", 2)
 427    budget = control.get("budget", {})
 428    budget_bits = []
 429    if budget.get("wall_clock_min") is not None:
 430        budget_bits.append(f"{budget.get('wall_clock_min')}m")
 431    if budget.get("usd") is not None:
 432        budget_bits.append(f"${budget.get('usd')}")
 433    if budget.get("tokens") is not None:
 434        budget_bits.append(f"{budget.get('tokens')} tokens")
 435    budget_text = ", ".join(budget_bits) or "configured caps"
 436
 437    lines: list[str] = []
 438    lines.extend(ascii_box("1. Goal + context", "read sources"))
 439    lines.extend(["               |", "               v"])
 440    lines.extend(ascii_box("2. Draft plan.md", f"state -> {observability.get('state_file', 'state.json')}"))
 441    lines.extend(["               |", "               v"])
 442    lines.extend(ascii_box("3. Plan gate", f"verdict: {plan_source}"))
 443    lines.extend([f"               | needs work -> revise <= {plan_revisions} -> step 2", "               | pass", "               v"])
 444    lines.extend(ascii_box("4. Write delivery-N.md", f"log -> {observability.get('run_log', 'run-log.md')}"))
 445    lines.extend(["               |", "               v"])
 446    lines.extend(ascii_box("5. Delivery gate", f"verdict: {delivery_source}"))
 447    lines.extend([f"               | needs work -> revise <= {delivery_revisions} -> step 4", "               | pass", "               v"])
 448    lines.extend(ascii_box("6. Final output", "all gates clean"))
 449    lines.extend(
 450        [
 451            "",
 452            f"Stops: pass gates | max {control.get('max_iterations')} iterations | "
 453            f"no progress x{stalled} | budget {budget_text}",
 454        ]
 455    )
 456    return "\n".join(lines)
深度阅读 · Runner.run 终止守卫与隐私边界 →
§ 03 · KEY FLOW

关键流程:compile → resolve → run

最值得拆的是 spec 在三段时间里扮演的不同角色:authoring 时它是带注释的人类可读 YAML;compile 时它是 normalize_spec 的输入;run 时它是被 load_json() 解析的 dict-form。resolved 阶段是 boundary——一次折叠换来两套 runner(外部 Python / 当前 session prompt)共享同一份 truth。

▸compile 流程

[user invokes /looper] target = ./looper-output ↓ [load YAML] load_yaml → PyYAML safe_load (sources/ksimback-looper/scripts/looper.py:74) → dict ↓ [normalize] normalize_spec → add criteria_by_id + council_by_id ↓ [write JSON] loop.resolved.json (canonical truth) ↓ ┌─────────────────────┴─────────────────────┐ ↓ ↓ [render LOOP.md] [render RUN_IN_SESSION.md] title + goal + DoD + verification 12-step operator instructions + council + gates + loop control + per-gate clauses + execution + observability + privacy + start-now ↓ [emit run-loop.py] copy from templates/ unchanged

▸cmd_compile — 三文件一次写

cmd_compile() 是 normalize_spec 的 dispatch 入口。它一次写三份产物:resolved JSON、LOOP.md (人类摘要)、RUN_IN_SESSION.md (当前 session prompt)。三份产物都从同一个 resolved dict 派生——保证作者改一处 YAML,三处产物都跟着重构。

scripts/looper.py · L726-743Python
 726def cmd_compile(args: argparse.Namespace) -> int:
 727    source = args.loop_yaml.resolve()
 728    spec = load_yaml(source)
 729    resolved = normalize_spec(spec, source)
 730    out = args.out or source.with_name("loop.resolved.json")
 731    write_json(out, resolved)
 732    if args.render:
 733        args.render.parent.mkdir(parents=True, exist_ok=True)
 734        args.render.write_text(render_loop(resolved), encoding="utf-8")
 735    if args.session_prompt:
 736        args.session_prompt.parent.mkdir(parents=True, exist_ok=True)
 737        args.session_prompt.write_text(render_session_prompt(resolved), encoding="utf-8")
 738    print(f"Wrote {out}")
 739    if args.render:
 740        print(f"Wrote {args.render}")
 741    if args.session_prompt:
 742        print(f"Wrote {args.session_prompt}")
 743    return 0

▸Runner.run — 外部 runner 的主驱动

子进程 + stdlib 实现:gather_context() sources/ksimback-looper/templates/run-loop.py:287 把 file/cmd context 写进 workspace/context.md 并 redact 默认敏感路径;跑 host 生成 plan.md;过 plan_gate;进入迭代循环:每轮 run_host("delivery", delivery-i.md) → run_gate("delivery_gate", ...);任一 gate 的 revision 上限到达或 budget 烧穿即停。三种 stop 状态写进 state.json (sources/ksimback-looper/templates/run-loop.py:543)。

templates/run-loop.py · L542-567Python
 542    def run(self) -> int:
 543        self.save_state(status="running")
 544        self.append_log("run_start", spec=str(self.spec_path))
 545        self.gather_context()
 546
 547        plan_path = self.workspace / "plan.md"
 548        if not plan_path.exists():
 549            self.run_host("plan", plan_path)
 550        if not self.run_gate("plan_gate", plan_path, "plan.md"):
 551            return 1
 552
 553        max_iterations = int(self.spec["loop_control"]["max_iterations"])
 554        for iteration in range(1, max_iterations + 1):
 555            self.enforce_wall_clock sources/ksimback-looper/templates/run-loop.py:178()
 556            self.save_state(status="delivery", iteration=iteration)
 557            delivery_path = self.workspace / f"delivery-{iteration}.md"
 558            self.run_host("delivery", delivery_path)
 559            if self.run_gate("delivery_gate", delivery_path, delivery_path.name):
 560                self.save_state(status="passed", final_delivery=str(delivery_path), completed_at=utc_now())
 561                self.append_log("run_passed", final_delivery=str(delivery_path))
 562                print(f"Looper run passed. Final delivery: {delivery_path}")
 563                return 0
 564
 565        self.save_state(status="failed", failure="max_iterations_reached")
 566        self.append_log("stop", reason="max_iterations_reached")
 567        return 1

▸run-loop.py — host / judge / reviewers 的 subprocess 边界

每一个 model 调用都走 subprocess.run(argv, ...) sources/ksimback-looper/templates/run-loop.py:84,argv 是 list 而非字符串;timeout_sec 来自 spec 各处;wall_clock 由 enforce_wall_clock() 用 time.monotonic() sources/ksimback-looper/templates/run-loop.py:154 在 enforce_wall_clock sources/ksimback-looper/templates/run-loop.py:178 在每次外部调用前检查;judge verdict 用 fence'd JSON 解析,无法解析就降级为 REVISE。这相当于在 judge 那里又加了一道 (sources/ksimback-looper/templates/run-loop.py:117-132 的两段 except 分支)"不解析就修"的兜底。

templates/run-loop.py · L112-140Python
 112def parse_judge_output(text: str) -> dict[str, Any]:
 113    fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
 114    candidate = fenced.group(1) if fenced else text.strip()
 115    try:
 116        parsed = json.loads(candidate)
 117    except json.JSONDecodeError:
 118        return {
 119            "verdict": REVISE,
 120            "blocking_issues sources/ksimback-looper/templates/run-loop.py:115": ["Judge output was not parseable JSON."],
 121            "confidence": 0.0,
 122            "notes": text.strip(),
 123            "warning": "unparseable_judge_output",
 124        }
 125    if not isinstance(parsed, dict):
 126        return {
 127            "verdict": REVISE,
 128            "blocking_issues": ["Judge output was not a JSON object."],
 129            "confidence": 0.0,
 130            "notes": text.strip(),
 131            "warning": "invalid_judge_output",
 132        }
 133    verdict = parsed.get("verdict")
 134    if verdict not in {PASS, REVISE}:
 135        parsed["verdict"] = REVISE
 136        parsed.setdefault("blocking_issues", []).append("Judge verdict was not pass or revise.")
 137    parsed.setdefault("blocking_issues", [])
 138    parsed.setdefault("confidence", 0.0)
 139    parsed.setdefault("notes", "")
 140    return parsed
深度阅读 · loop.yaml schema 全字段分解 →
§ 04 · TESTS & VALIDATION

终止守卫、隐私边界与测试策略

这一章专门回答:"loop 跑飞了怎么办?敏感内容会不会被回传到另一个 vendor?"——这两个问题是 Looper 比同代 loop 工具更值得用的核心理由。

▸run_gate — 带 revision 上限的裁判 while-loop

每个 gate 是一个 while True::先收 programmatic / human failures,再过 reviewer notes,revise_until_clean 时让 judge 给 pass/revise + blocking_issues,失败累加。如果 no_progress_reached() 触发或 revision >= max_revisions 就写状态并 return False。每一次 verdict、revision、stop 都写 run-log.md,事件名是稳定的 key。

templates/run-loop.py · L451-540Python
 451    def run_gate(self, gate_name: str, artifact_path: Path, artifact_label: str) -> bool:
 452        gate = self.spec["gates"][gate_name]
 453        criteria = self.criteria(gate.get("criteria", []))
 454        max_revisions = int(gate.get("max_revisions", 0))
 455        revision = 0
 456        self.append_log("gate_start", gate=gate_name, artifact=artifact_label)
 457
 458        while True:
 459            self.enforce_wall_clock()
 460            artifact_text = artifact_path.read_text(encoding="utf-8")
 461            review_parts: list[str] = []
 462            failures: list[str] = []
 463
 464            for criterion in criteria:
 465                if criterion["type"] == "programmatic":
 466                    result = self.run_programmatic(criterion)
 467                    review_parts.append(f"## Programmatic {criterion['id']}\n\n```json\n{json.dumps(result, indent=2)}\n```")
 468                    if not result["passed"]:
 469                        failures.append(f"Programmatic check failed: {criterion['id']}")
 470                elif criterion["type"] == "human":
 471                    result = self.human_check(criterion)
 472                    review_parts.append(f"## Human {criterion['id']}\n\n{result['notes']}")
 473                    if not result["passed"]:
 474                        failures.append(f"Human check failed: {criterion['id']}")
 475
 476            reviewer_notes = self.run_reviewers(
 477                gate_name,
 478                artifact_label,
 479                artifact_text,
 480                list(gate.get("members", [])),
 481            )
 482            review_parts.extend(reviewer_notes)
 483
 484            policy = gate.get("verdict_policy")
 485            verdict: dict[str, Any] | None = None
 486            if policy == "revise_until_clean" and not failures:
 487                source = gate.get("verdict_source")
 488                if source == "human":
 489                    answer = input(f"Type 'pass' if {artifact_label} is clean: ").strip().lower()
 490                    verdict = {
 491                        "verdict": PASS if answer == PASS else REVISE,
 492                        "blocking_issues": [] if answer == PASS else ["human requested revision"],
 493                        "confidence": 1.0,
 494                        "notes": "human verdict",
 495                    }
 496                else:
 497                    verdict = self.run_judge(source, gate_name, artifact_label, artifact_text, criteria)
 498                review_parts.append(f"## Verdict\n\n```json\n{json.dumps(verdict, indent=2)}\n```")
 499                if verdict.get("verdict") == REVISE:
 500                    failures.extend(verdict.get("blocking_issues") or ["Judge requested revision"])
 501
 502            if policy == "fixed_passes":
 503                if failures:
 504                    pass
 505                elif revision >= max_revisions:
 506                    return True
 507                else:
 508                    failures.append("fixed_passes reviewer pass")
 509
 510            if not failures:
 511                self.save_state(status=f"{gate_name}_passed", **{gate_name: {"passed_at": utc_now()}})
 512                self.append_log("gate_passed", gate=gate_name, artifact=artifact_label)
 513                return True
 514
 515            review_text = "\n\n".join(review_parts + ["## Blocking Issues", "\n".join(f"- {item}" for item in failures)])
 516            review_path = self.workspace / f"review-{gate_name}-{revision + 1}.md"
 517            write_text(review_path, review_text)
 518            self.append_log("gate_blocked", gate=gate_name, review=review_path.name, failures=failures)
 519
 520            if self.no_progress_reached(gate_name, failures):
 521                return False
 522
 523            if revision >= max_revisions:
 524                self.save_state(
 525                    status="failed",
 526                    failure=f"{gate_name}_max_revisions_reached",
 527                    last_review=str(review_path),
 528                )
 529                self.append_log("stop", reason=f"{gate_name}_max_revisions_reached")
 530                return False
 531
 532            revised = call_model sources/ksimback-looper/templates/run-loop.py:101(
 533                self.spec["host"],
 534                self.host_prompt("revise", artifact_text, review_text),
 535                self.base_dir,
 536            )
 537            write_text(artifact_path, revised)
 538            revision += 1
 539            self.save_state(status=f"{gate_name}_revision_{revision}", last_review=str(review_path))
 540            self.append_log("revision", gate=gate_name, revision=revision, artifact=artifact_label)

▸no_progress_reached — 同 signature 致死

这是 Looper 的灵魂护栏。每次 gate 失败都把 sorted(failures) 做 signature,与 state.json 里的 previous signature 比对:相同就 +1,否则归零。如果连续达到 max_stalled_iterations sources/ksimback-looper/templates/run-loop.py:193 默认 2 (sources/ksimback-looper/scripts/looper.py:311)(默认 2),按 action 是 stop 还是 human_checkpoint 分叉 (sources/ksimback-looper/templates/run-loop.py:210-218 action ∈ {stop, human_checkpoint})——前者直接标 failed,后者给用户一次 "continue" 的机会。即使 action 是 stop,这里仍给出 blocking_issues 让人工翻看。

templates/run-loop.py · L188-218Python
 188    def no_progress_reached(self, gate_name: str, failures: list[str]) -> bool:
 189        if not failures:
 190            self.save_state(no_progress={"count": 0, "signature": "", "gate": gate_name})
 191            return False
 192        config = self.spec.get("loop_control", {}).get("no_progress", {})
 193        threshold = int(config.get("max_stalled_iterations", 2))
 194        signature = "\n".join(sorted(failures))
 195        previous = self.state.get("no_progress", {})
 196        same_gate = previous.get("gate") == gate_name
 197        same_signature = previous.get("signature") == signature
 198        count = int(previous.get("count", 0)) + 1 if same_gate and same_signature else 1
 199        progress = {
 200            "gate": gate_name,
 201            "signature": signature,
 202            "count": count,
 203            "threshold": threshold,
 204            "updated_at": utc_now(),
 205        }
 206        self.save_state(no_progress=progress)
 207        if count < threshold:
 208            return False
 209        self.append_log("no_progress_detected", gate=gate_name, count=count, failures=failures)
 210        if config.get("action", "stop") == "human_checkpoint":
 211            answer = input("No-progress detected. Type 'continue' to allow one more revision: ").strip().lower()
 212            if answer == "continue":
 213                progress["count"] = 0
 214                self.save_state(no_progress=progress)
 215                self.append_log("no_progress_override", gate=gate_name)
 216                return False
 217        self.save_state(status="failed", failure="no_progress_detected", blocking_issues=failures)
 218        return True

▸redact_prompt_for_member — 转译给 council 之前先 redact

这是隐私一票否决的边界。gather_context() 先按默认 redaction globs 排除 context source;redact_prompt_for_member() 又把 .env / secrets/** / **/*.key 整段内容从 prompt 里 替换 成 [redacted:相对路径],并对每行长度 ≥ 8 的 stripped 行再替换一次——多一道防御,因为 .env 里常出现类似 OPENAI_API_KEY=sk-... 的整行值。读文件不上 UnicodeDecodeError > 1MB 自动跳过,避免 secret 内容走 serde 路径爆炸。

templates/run-loop.py · L234-258Python
 234    def redact_prompt_for_member(self, member_id: str, prompt: str) -> str:
 235        redactions = self.redactions_for(member_id)
 236        redacted = prompt
 237        for pattern in redactions:
 238            paths = list(self.base_dir.glob(pattern))
 239            if pattern.endswith("/**"):
 240                root = self.base_dir / pattern[:-3]
 241                if root.exists():
 242                    paths.extend(root.rglob("*"))
 243            for path in paths:
 244                if not path.is_file():
 245                    continue
 246                try:
 247                    secret_text = path.read_text(encoding="utf-8")
 248                except UnicodeDecodeError:
 249                    continue
 250                if not secret_text.strip() or len(secret_text) > 1_000_000:
 251                    continue
 252                marker = f"[redacted:{path.relative_to(self.base_dir).as_posix()}]"
 253                redacted = redacted.replace(secret_text, marker)
 254                for line in secret_text.splitlines():
 255                    stripped = line.strip()
 256                    if len(stripped) >= 8:
 257                        redacted = redacted.replace(stripped, marker)
 258        return redacted

▸ensure_consent — 跨 vendor 第一次发送前人审

对每一个非 local 的 council member,runner 检查 privacy.egress 里它有没有 consent: required 的 entry,没记住 consent 就 input() 让用户输 "yes"。回话仅记在 state.json 的 consent map 里,写明 granted_at / sends / redact,复盘时一目了然。

templates/run-loop.py · L260-286Python
 260    def ensure_consent(self, member_id: str) -> None:
 261        member = self.member(member_id)
 262        if member.get("local"):
 263            return
 264        matching = [
 265            entry
 266            for entry in self.spec.get("privacy", {}).get("egress", [])
 267            if entry.get("to") == member_id and entry.get("consent") == "required"
 268        ]
 269        if not matching:
 270            return
 271        if self.state.get("consent", {}).get(member_id):
 272            return
 273        sends = sorted({item for entry in matching for item in entry.get("sends", [])})
 274        redactions = sorted({item for entry in matching for item in entry.get("redact", [])})
 275        print()
 276        print(f"Looper is about to send {', '.join(sends) or 'context'} to {member_id}.")
 277        print(f"CLI: {member.get('cli')} / model: {member.get('model', 'default')}")
 278        print(f"Redactions: {', '.join(redactions) or '(none)'}")
 279        answer = input("Type 'yes' to consent to this first send: ").strip().lower()
 280        if answer != "yes":
 281            self.save_state(status="blocked", failure=f"consent_refused:{member_id}")
 282            raise RunnerError(f"Consent refused for {member_id}")
 283        consent = dict(self.state.get("consent", {}))
 284        consent[member_id] = {"granted_at": utc_now(), "sends": sends, "redact": redactions}
 285        self.save_state(consent=consent)
 286

▸测试覆盖 — fixture 完整、纯 stdlib

测试 tests/test_looper.py 用 write_loop_yaml() 在 tmpdir 里拼出一份完整 YAML,再分别以 judge=judge / judge=reviewer 两条路径跑 tests.fixtures.fake_host.py 与 fake_judge.py sources/ksimback-looper/tests/fixtures/fake_judge.py:11。judge 脚本读外部 state 文件计数:count < revise-count 就 revise,否则 pass。这样 runner 整套 revise_until_clean / no_progress / consent 路径都能在 CI 里跑通而不依赖真实 CLI——这与 设计期 让用户访谈 7 轮的做法一脉相承:重要路径都要可重复验证。

tests/fixtures/fake_judge.py · L1-47Python
   1#!/usr/bin/env python3
   2"""Fake judge model for Looper runner tests."""
   3
   4from __future__ import annotations
   5
   6import json
   7from pathlib import Path
   8import sys
   9
  10
  11def main() -> int:
  12    if len(sys.argv) < 2:
  13        print("usage: fake_judge.py <state-file> [revise-count]", file=sys.stderr)
  14        return 2
  15    state_path = Path(sys.argv[1])
  16    revise_count = int(sys.argv[2]) if len(sys.argv) > 2 else 0
  17    state_path.parent.mkdir(parents=True, exist_ok=True)
  18    state = {"count": 0}
  19    if state_path.exists():
  20        state = json.loads(state_path.read_text(encoding="utf-8"))
  21    count = int(state.get("count", 0))
  22    state["count"] = count + 1
  23    state_path.write_text(json.dumps(state), encoding="utf-8")
  24
  25    if count < revise_count:
  26        verdict = {
  27            "verdict": "revise",
  28            "blocking_issues": ["Add an explicit owner."],
  29            "confidence": 0.91,
  30            "notes": "The artifact is close but needs one concrete owner.",
  31        }
  32    else:
  33        verdict = {
  34            "verdict": "pass",
  35            "blocking_issues": [],
  36            "confidence": 0.94,
  37            "notes": "The artifact satisfies the rubric.",
  38        }
  39    print("```json")
  40    print(json.dumps(verdict))
  41    print("```")
  42    return 0
  43
  44
  45if __name__ == "__main__":
  46    raise SystemExit(main())
  47

▸已知边界 / 失败模式

  • judge 输出非 JSON 时降级为 REVISE — parse_judge_output sources/ksimback-looper/templates/run-loop.py:112 sources/ksimback-looper/templates/run-loop.py:112 L112-140 把 fence'd JSON 兜底切成原文本,都失败就 verdict=REVISE + blocking_issues=["Judge output was not parseable JSON."],可能在边界场景死循环。
  • no_progress 仅比 failures 文本签名 — 同样一句 blocking 可能错位匹配;下一轮改写就归零。
  • secret redaction 走文件路径而非内容指纹 — 如果用户把 API_KEY 复制到 notes.md 不会被 redact,只能保证默认 glob。
  • wall_clock 用 time.monotonic() 在外部调用前检查 — host / judge 自身耗时不计入;budget 烧穿的具体耗时是 > wall_clock_min。
  • 外部 runner 必须拿到 PyYAML 才能 compile (sources/ksimback-looper/scripts/looper.py:66-70 在 load_yaml 里强制) — run-loop.py 是 stdlib only,但 compile 阶段仍要 PyYAML;运行期与设计期的依赖面不一致。
§ 05 · HIGHLIGHTS

技术亮点

1 · 把设计期和运行期拆开
Looper 自己不能调用 model (`sources/ksimback-looper/scripts/looper.py:2-6` docstring 自己说 "must not invoke model CLIs"),只产出文件。约束来自 looper-spec.md 的 "Looper's own process never invokes a model to do loop work",是 hard rule 不是 vibe。
2 · 两套 runner 共享一份 resolved JSON
外部 Python runner (run-loop.py) 与当前 session 跑 RUN_IN_SESSION.md,从同一份 loop.resolved.json sources/ksimback-looper/scripts/looper.py:393 派生;改 YAML 一处,三处产物一致。
3 · typed verification 是 first-class
programmatic / judge / human 三类先入 schema 再入 normalize_spec,LooperError 防止用户写弱 rubric,doc 强制"至少一条不是 vibe-based"。
4 · reviewer ≠ judge 强制分离
reviewer.role 写笔记,judge.role 出 pass/revise + confidence (sources/ksimback-looper/templates/run-loop.py:131) + blocking_issues;validate_gate 强校验 verdict_source 必须是 judge 角色或 human。
5 · argv 数组而非 shell 字符串
每个 model invocation、每条 programmatic check 都用 list[str];runner 用 subprocess.run(argv, ...) 直调,避免 shell injection 与 quoting 噩梦。
6 · 三重 termination guard
max_iterations 控制总轮;每个 gate 的 max_revisions 控制单条改稿上限;no_progress.signature 控制"同一失败堆积 X 次";再加 budget USD/tokens/wall_clock 任一超限停。
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

源码看下来有意或无意的取舍:

a · Looper 不假装是 orchestrator
作者在 looper-spec.md §2 把"Looper is a scaffolder and session handoff, not an orchestration framework" 作为 hard rule 写进设计文档,loop 真的需要长跑 / checkpointed restart / 并发调度,Looper 应该交出去。
b · MODEL_PROBES 写死 5 个 CLI
注册 model 的 escape hatch 是 register-model 子命令,新 vendor 进来要手动 --invoke 一遍,没有自动发现。Aider、Gemini CLI 等较新的非 Claude/Codex 工具不会出现。
c · no_progress = 文本签名
如果用户把同一 blocking issue 的措辞稍微修改("Add a check" → "Please add a check"),signature 归零,no_progress 自动重启 (sources/ksimback-looper/templates/run-loop.py:198 同样 signature 才 +1) (sources/ksimback-looper/templates/run-loop.py:198 同一 gate 同样 signature 才 +1)——一种 false negative,可能被无限循环拖死。
d · redact 用 path glob 不做 content 探测
默认 .env + secrets/** + **/*.key (sources/ksimback-looper/scripts/looper.py:23 默认 glob);如果 secret 被复制到 README、process-notes.md、an inline attachment,runner 不会拦。需要自己加 privacy.egress.redact。
e · budget USD/tokens 靠外部记账
looper-spec sources/ksimback-looper/looper-spec.md:23 把 USD / tokens 列为 operator-acknowledged 的预算,runner 自己不会去解析 CLI 输出统计实际 token 消耗。预算烧穿靠 wall_clock_min 做兜底,精度有限。
f · examples 只有一个
examples/ai-workflow-mapping/ 是唯一现实示例,host=codex exec、council judge=claude -p。其他组合(host=claude + judge=codex,host=llm + council=ollama)只能用户自己翻 SKILL.md 拼。

▸源码引用清单一览

本页所有 source:line 引用都已在 GitHub 源码中核对行号。下面是引用密度最高的 18 个锚点(按文件分组):

scripts/looper.py
sources/ksimback-looper/scripts/looper.py:23 MODEL_PROBES
sources/ksimback-looper/scripts/looper.py:60 LooperError
sources/ksimback-looper/scripts/looper.py:122 run_probe
sources/ksimback-looper/scripts/looper.py:145 detect_models
sources/ksimback-looper/scripts/looper.py:224 validate_gate
sources/ksimback-looper/scripts/looper.py:254 normalize_spec
sources/ksimback-looper/scripts/looper.py:415 render_ascii_diagram
sources/ksimback-looper/scripts/looper.py:726 cmd_compile
sources/ksimback-looper/scripts/looper.py:708 cmd_register
templates/run-loop.py
sources/ksimback-looper/templates/run-loop.py:101 call_model
sources/ksimback-looper/templates/run-loop.py:112 parse_judge_output
sources/ksimback-looper/templates/run-loop.py:143 Runner.__init__
sources/ksimback-looper/templates/run-loop.py:178 enforce_wall_clock
sources/ksimback-looper/templates/run-loop.py:188 no_progress_reached
sources/ksimback-looper/templates/run-loop.py:234 redact_prompt_for_member
sources/ksimback-looper/templates/run-loop.py:260 ensure_consent
sources/ksimback-looper/templates/run-loop.py:451 run_gate
sources/ksimback-looper/templates/run-loop.py:542 Runner.run
schemas/ + tests/
sources/ksimback-looper/schemas/loop.v1.schema.json:30 verification
sources/ksimback-looper/schemas/loop.v1.schema.json:66 loop_control
sources/ksimback-looper/tests/fixtures/fake_judge.py:11 revise state
sources/ksimback-looper/tests/fixtures/fake_host.py:9 prompt switch
Markdown 顶层
sources/ksimback-looper/SKILL.md:1 front-matter
sources/ksimback-looper/SKILL.md:54 emit checklist
sources/ksimback-looper/looper-spec.md:23 "hard rule"约束
诚实记录 — 这部分列的是我在源码里看到、但作者没有明确文档化的限制。读者请按需取舍。如果你要靠 Looper 做生产级 multi-day 编排,建议先读 looper-spec.md §2 的硬性边界。