looperspec & normalize
§ · Nested detail page

loop.yaml → resolved.json 深度解析

scripts/looper.py 里 normalize_spec 与 validate_gate 的 100+ 行校验逻辑 — 把 50 行 YAML 折叠成 250 行结构化 JSON,为外部 runner 提供单一可信源。

3
关键函数
9
顶层 key
2
Gates
4
Default globs
§ 00 · IMPORT CHAIN

导入链与契约

本子页聚焦 sources/ksimback-looper/scripts/looper.py 中三个函数:load_yaml → criteria_by_id → validate_gate → normalize_spec。它们就是 compile 子命令的真正工作面。cmd_compile() 只做 dispatch;本子页引用的所有 path:line 都已对源文件 grep 验证。三个函数的契约是:"只要任何一项不合规,抛 LooperError;只要全合规,返回的 dict 一定能被 runner 直接 load 跑"。

scripts/looper.py · L1-23Python
   1#!/usr/bin/env python3
   2"""Looper helper CLI.
   3
   4This script belongs to the scaffolding side of Looper. It may detect installed
   5CLIs, register invocation metadata, compile loop.yaml to loop.resolved.json, and
   6render LOOP.md. It must not invoke model CLIs to do loop work.
   7"""
   8
   9from __future__ import annotations
  10
  11import argparse
  12import datetime as _dt
  13import json
  14import os
  15from pathlib import Path
  16import shlex
  17import shutil
  18import subprocess
  19import sys
  20from typing import Any
  21
  22
  23DEFAULT_REDACTIONS = [".env", ".env.*", "secrets/**", "**/*.key"]

顶部 docstring 给的是 hard rule:This script belongs to the scaffolding side of Looper. 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. sources/ksimback-looper/scripts/looper.py:4。这就是为什么所有 model 调用(含 detect_models)都用 subprocess.run 不接受 stdin 文本——避免误把 LLM 任务塞进来。

▸load_yaml / load_json — 严格区分两种 loader

YAML 路径只用于 compile 阶段,需要 PyYAML;JSON 路径用于 runner 阶段,stdlib only。load_yaml 显式检查 import yaml sources/ksimback-looper/scripts/looper.py:66 失败就抛 LooperError;load_json 用纯 stdlib,是 runner 唯一入口。两套代码不共享,正确反映了"运行时不要 PyYAML"的设计。

scripts/looper.py · L64-95Python
  64def load_yaml(path: Path) -> dict[str, Any]:
  65    try:
  66        import yaml  # type: ignore
  67    except ImportError as exc:
  68        raise LooperError(
  69            "PyYAML is required to compile loop.yaml. Install with: python -m pip install PyYAML"
  70        ) from exc
  71
  72    try:
  73        with path.open("r", encoding="utf-8") as fh:
  74            data = yaml.safe_load(fh)
  75    except OSError as exc:
  76        raise LooperError(f"Could not read {path}: {exc}") from exc
  77    except yaml.YAMLError as exc:
  78        raise LooperError(f"Could not parse YAML in {path}: {exc}") from exc
  79    if not isinstance(data, dict):
  80        raise LooperError(f"{path} must contain a YAML mapping at the top level")
  81    return data
  82
  83
  84def load_json(path: Path) -> dict[str, Any]:
  85    with path.open("r", encoding="utf-8") as fh:
  86        data = json.load(fh)
  87    if not isinstance(data, dict):
  88        raise LooperError(f"{path} must contain a JSON object")
  89    return data
  90
  91
  92def write_json(path: Path, data: Any) -> None:
  93    path.parent.mkdir(parents=True, exist_ok=True)
  94    path.write_text(json.dumps(to_jsonable(data), indent=2, sort_keys=True) + "\n", encoding="utf-8")
  95
§ 01 · CORE IMPL

核心实现,逐段拆

▸criteria_by_id — 把 verification 数组折叠成 dict

YAML 里 goal.verification 是一个数组,每项有 id / type / check 等字段。criteria_by_id() sources/ksimback-looper/scripts/looper.py:177 用 id 当 key 折叠成 dict,然后再 normalize 各类型——programmatic 要有 check argv + expect ∈ {exit_zero, exit_nonzero, stdout_contains};judge 要有 rubric;human 要有 prompt。每一条不合规直接抛 LooperError sources/ksimback-looper/scripts/looper.py:60,意味着 schema 不只是 validate,还 强约束输入形状。

scripts/looper.py · L177-220Python
 177def criteria_by_id(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
 178    criteria = spec.get("goal", {}).get("verification", [])
 179    if not isinstance(criteria, list):
 180        raise LooperError("goal.verification must be a list")
 181    result: dict[str, dict[str, Any]] = {}
 182    for item in criteria:
 183        if not isinstance(item, dict):
 184            raise LooperError("Each verification criterion must be an object")
 185        cid = item.get("id")
 186        ctype = item.get("type")
 187        if not isinstance(cid, str) or not cid:
 188            raise LooperError("Each verification criterion needs a non-empty id")
 189        if cid in result:
 190            raise LooperError(f"Duplicate verification criterion id: {cid}")
 191        if ctype not in {"programmatic", "judge", "human"}:
 192            raise LooperError(f"Criterion {cid} has invalid type: {ctype}")
 193        if ctype == "programmatic":
 194            item["check"] = normalize_argv(item.get("check"), f"criterion {cid}.check")
 195            if item.get("expect") not in {"exit_zero", "exit_nonzero", "stdout_contains"}:
 196                raise LooperError(
 197                    f"Criterion {cid}.expect must be exit_zero, exit_nonzero, or stdout_contains"
 198                )
 199            if item.get("expect") == "stdout_contains" and not isinstance(item.get("contains"), str):
 200                raise LooperError(f"Criterion {cid} with stdout_contains needs contains")
 201        elif ctype == "judge" and not isinstance(item.get("rubric"), str):
 202            raise LooperError(f"Criterion {cid} needs a judge rubric")
 203        elif ctype == "human" and not isinstance(item.get("prompt"), str):
 204            raise LooperError(f"Criterion {cid} needs a human prompt")
 205        result[cid] = item
 206    return result
 207
 208
 209def validate_member(member: dict[str, Any]) -> None:
 210    mid = member.get("id")
 211    role = member.get("role")
 212    if not isinstance(mid, str) or not mid:
 213        raise LooperError("Each council member needs a non-empty id")
 214    if role not in {"reviewer", "judge"}:
 215        raise LooperError(f"Council member {mid} role must be reviewer or judge")
 216    member["invoke"] = normalize_argv(member.get("invoke"), f"council.{mid}.invoke")
 217    timeout = member.get("timeout_sec", 600)
 218    if not isinstance(timeout, int) or timeout <= 0:
 219        raise LooperError(f"Council member {mid}.timeout_sec must be a positive integer")
 220    member.setdefault("scope", ["plan", "delivery"])

▸validate_gate — 三段交叉引用检查

sources/ksimback-looper/scripts/looper.py:224 的 validate_gate() 是 gate 的真正守门员。它做四件事:(1) verdict_policy ∈ {revise_until_clean, fixed_passes};(2) max_revisions 是非负整数;(3) 每个 criterion id 必须已注册到 criteria_by_id;(4) 每个 member id 必须在 council 里。revise_until_clean 还强制 verdict_source 必须是 judge 角色或 human sources/ksimback-looper/scripts/looper.py:245-251——这是 reviewer vs judge 分离的核心代码锚点。

scripts/looper.py · L224-252Python
 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")
 252

▸normalize_spec — 100 行 setdefault 总动员

sources/ksimback-looper/scripts/looper.py:254 的 normalize_spec() 是整个文件最长的函数。它开篇就是 if spec.get("version") != 1: raise LooperError("Only loop.yaml version: 1 is supported"),然后逐一对 goal.statement / definition_of_done / context_sources[*] / criteria / host / council / gates / loop_control / execution / observability / workspace / privacy 做规范化。规范化的核心套路是:不变就 raise,可选就用 setdefault 注入默认值。这一双轨制让 Looper 既能拒绝坏输入,又能给常规模板自动补全。

scripts/looper.py · L254-401Python
 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
 291    gates = spec.get("gates")
 292    if not isinstance(gates, dict):
 293        raise LooperError("gates must be an object")
 294    for gate_name in ("plan_gate", "delivery_gate"):
 295        validate_gate(gate_name, gates.get(gate_name), criteria, members)
 296
 297    control = spec.get("loop_control")
 298    if not isinstance(control, dict):
 299        raise LooperError("loop_control must be an object")
 300    max_iterations = control.get("max_iterations")
 301    if not isinstance(max_iterations, int) or max_iterations <= 0:
 302        raise LooperError("loop_control.max_iterations must be a positive integer")
 303    budget = control.setdefault("budget", {})
 304    if not isinstance(budget, dict):
 305        raise LooperError("loop_control.budget must be an object")
 306    if "wall_clock_min" not in budget:
 307        budget["wall_clock_min"] = 30
 308    no_progress = control.setdefault(
 309        "no_progress",
 310        {
 311            "max_stalled_iterations": 2,
 312            "signals": [
 313                "same blocking issue repeats",
 314                "delivery artifact has no material change",
 315                "verifier output is unchanged",
 316            ],
 317            "action": "stop",
 318        },
 319    )
 320    if not isinstance(no_progress, dict):
 321        raise LooperError("loop_control.no_progress must be an object")
 322    stalled = no_progress.setdefault("max_stalled_iterations", 2)
 323    if not isinstance(stalled, int) or stalled <= 0:
 324        raise LooperError("loop_control.no_progress.max_stalled_iterations must be a positive integer")
 325    signals = no_progress.setdefault("signals", ["same blocking issue repeats"])
 326    if not isinstance(signals, list) or not all(isinstance(item, str) for item in signals):
 327        raise LooperError("loop_control.no_progress.signals must be a list of strings")
 328    action = no_progress.setdefault("action", "stop")
 329    if action not in {"stop", "human_checkpoint"}:
 330        raise LooperError("loop_control.no_progress.action must be stop or human_checkpoint")
 331
 332    execution = spec.setdefault(
 333        "execution",
 334        {
 335            "mode": "in_session",
 336            "isolation": "current_workspace",
 337            "side_effects": {"requires_approval": True, "duplicate_action_check": True},
 338        },
 339    )
 340    if not isinstance(execution, dict):
 341        raise LooperError("execution must be an object")
 342    execution.setdefault("mode", "in_session")
 343    execution.setdefault("isolation", "current_workspace")
 344    if execution["mode"] not in {"in_session", "external_runner", "orchestrated"}:
 345        raise LooperError("execution.mode must be in_session, external_runner, or orchestrated")
 346    if execution["isolation"] not in {"current_workspace", "branch", "worktree", "sandbox"}:
 347        raise LooperError("execution.isolation must be current_workspace, branch, worktree, or sandbox")
 348    side_effects = execution.setdefault("side_effects", {})
 349    if not isinstance(side_effects, dict):
 350        raise LooperError("execution.side_effects must be an object")
 351    side_effects.setdefault("requires_approval", True)
 352    side_effects.setdefault("duplicate_action_check", True)
 353
 354    observability = spec.setdefault(
 355        "observability",
 356        {"state_file": "state.json", "run_log": "run-log.md", "checkpoint_granularity": "gate"},
 357    )
 358    if not isinstance(observability, dict):
 359        raise LooperError("observability must be an object")
 360    observability.setdefault("state_file", "state.json")
 361    observability.setdefault("run_log", "run-log.md")
 362    observability.setdefault("checkpoint_granularity", "gate")
 363    if not isinstance(observability["state_file"], str) or not observability["state_file"]:
 364        raise LooperError("observability.state_file must be a non-empty string")
 365    if not isinstance(observability["run_log"], str) or not observability["run_log"]:
 366        raise LooperError("observability.run_log must be a non-empty string")
 367    if observability["checkpoint_granularity"] not in {"gate", "step"}:
 368        raise LooperError("observability.checkpoint_granularity must be gate or step")
 369
 370    workspace = spec.setdefault("workspace", {})
 371    if not isinstance(workspace, dict):
 372        raise LooperError("workspace must be an object")
 373    workspace.setdefault("dir", "./loop-workspace")
 374    layout = workspace.setdefault("layout", ["plan.md", "delivery-{n}.md", "review-{n}.md", "state.json", "run-log.md"])
 375    if not isinstance(layout, list) or not all(isinstance(item, str) for item in layout):
 376        raise LooperError("workspace.layout must be a list of strings")
 377    for required_file in (observability["state_file"], observability["run_log"]):
 378        if required_file not in layout:
 379            layout.append(required_file)
 380
 381    privacy = spec.setdefault("privacy", {})
 382    if not isinstance(privacy, dict):
 383        raise LooperError("privacy must be an object")
 384    egress = privacy.setdefault("egress", [])
 385    if not isinstance(egress, list):
 386        raise LooperError("privacy.egress must be a list")
 387    for entry in egress:
 388        if not isinstance(entry, dict):
 389            raise LooperError("privacy.egress entries must be objects")
 390        entry.setdefault("redact", DEFAULT_REDACTIONS)
 391        entry.setdefault("consent", "required")
 392
 393    resolved = {
 394        "$schema": "https://github.com/ksimback/looper/schema/loop.resolved.v1.json",
 395        "compiled_at": _dt.datetime.now(_dt.UTC).replace(microsecond=0).isoformat(),
 396        "source": str(source_path),
 397        **spec,
 398        "criteria_by_id": criteria,
 399        "council_by_id": members,
 400    }
 401    return to_jsonable(resolved)

▸loop.v1.schema.json — 同源对应的 schema 视图

Schema 用 JSON Schema draft 2020-12 写。它是 authoring-time 校验,跟 normalize_spec 互补——schema 拒绝结构性错(缺必填 key、错类型),normalize_spec 拒绝语义性错(argv 数组非 list、criteria id 重复)。schema 还有 $defs block(见 §01 gate / model_invocation / criterion 三类定义),但本子页重点是它和 normalize_spec 的对应关系,不是类型签名。

schemas/loop.v1.schema.json · L1-100JSON
   1{
   2  "$schema": "https://json-schema.org/draft/2020-12/schema",
   3  "$id": "https://github.com/ksimback/looper/schema/loop.v1.json",
   4  "title": "Looper authoring spec v1",
   5  "type": "object",
   6  "required": ["version", "goal", "host", "gates", "loop_control", "workspace"],
   7  "properties": {
   8    "version": { "const": 1 },
   9    "meta": {
  10      "type": "object",
  11      "additionalProperties": true
  12    },
  13    "goal": {
  14      "type": "object",
  15      "required": ["statement", "definition_of_done", "verification"],
  16      "properties": {
  17        "statement": { "type": "string", "minLength": 1 },
  18        "definition_of_done": { "type": "string", "minLength": 1 },
  19        "context_sources": {
  20          "type": "array",
  21          "items": {
  22            "type": "object",
  23            "anyOf": [
  24              { "required": ["file"] },
  25              { "required": ["cmd"] }
  26            ]
  27          }
  28        },
  29        "verification": {
  30          "type": "array",
  31          "items": { "$ref": "#/$defs/criterion" }
  32        }
  33      },
  34      "additionalProperties": true
  35    },
  36    "host": { "$ref": "#/$defs/model_invocation" },
  37    "council": {
  38      "type": "array",
  39      "items": {
  40        "allOf": [
  41          { "$ref": "#/$defs/model_invocation" },
  42          {
  43            "type": "object",
  44            "required": ["id", "role"],
  45            "properties": {
  46              "id": { "type": "string", "minLength": 1 },
  47              "role": { "enum": ["reviewer", "judge"] },
  48              "scope": {
  49                "type": "array",
  50                "items": { "type": "string" }
  51              },
  52              "local": { "type": "boolean" }
  53            }
  54          }
  55        ]
  56      }
  57    },
  58    "gates": {
  59      "type": "object",
  60      "required": ["plan_gate", "delivery_gate"],
  61      "properties": {
  62        "plan_gate": { "$ref": "#/$defs/gate" },
  63        "delivery_gate": { "$ref": "#/$defs/gate" }
  64      }
  65    },
  66    "loop_control": {
  67      "type": "object",
  68      "required": ["max_iterations"],
  69      "properties": {
  70        "max_iterations": { "type": "integer", "minimum": 1 },
  71        "budget": { "type": "object" },
  72        "no_progress": {
  73          "type": "object",
  74          "properties": {
  75            "max_stalled_iterations": { "type": "integer", "minimum": 1 },
  76            "signals": {
  77              "type": "array",
  78              "items": { "type": "string" }
  79            },
  80            "action": { "enum": ["stop", "human_checkpoint"] }
  81          },
  82          "additionalProperties": true
  83        },
  84        "human_checkpoints": {
  85          "type": "array",
  86          "items": { "type": "string" }
  87        },
  88        "stop_conditions": {
  89          "type": "array",
  90          "items": { "type": "string" }
  91        }
  92      }
  93    },
  94    "execution": {
  95      "type": "object",
  96      "properties": {
  97        "mode": { "enum": ["in_session", "external_runner", "orchestrated"] },
  98        "isolation": { "enum": ["current_workspace", "branch", "worktree", "sandbox"] },
  99        "side_effects": { "type": "object" }
 100      },
schemas/loop.v1.schema.json · L100-190JSON
 100      },
 101      "additionalProperties": true
 102    },
 103    "observability": {
 104      "type": "object",
 105      "properties": {
 106        "state_file": { "type": "string" },
 107        "run_log": { "type": "string" },
 108        "checkpoint_granularity": { "enum": ["gate", "step"] }
 109      },
 110      "additionalProperties": true
 111    },
 112    "privacy": { "type": "object" },
 113    "workspace": {
 114      "type": "object",
 115      "required": ["dir"],
 116      "properties": {
 117        "dir": { "type": "string", "minLength": 1 },
 118        "layout": {
 119          "type": "array",
 120          "items": { "type": "string" }
 121        }
 122      }
 123    }
 124  },
 125  "$defs": {
 126    "argv": {
 127      "type": "array",
 128      "minItems": 1,
 129      "items": { "type": "string" }
 130    },
 131    "model_invocation": {
 132      "type": "object",
 133      "required": ["cli", "invoke"],
 134      "properties": {
 135        "cli": { "type": "string" },
 136        "model": { "type": "string" },
 137        "invoke": { "$ref": "#/$defs/argv" },
 138        "timeout_sec": { "type": "integer", "minimum": 1 }
 139      },
 140      "additionalProperties": true
 141    },
 142    "criterion": {
 143      "type": "object",
 144      "required": ["id", "type"],
 145      "oneOf": [
 146        {
 147          "properties": {
 148            "type": { "const": "programmatic" },
 149            "check": { "$ref": "#/$defs/argv" },
 150            "expect": { "enum": ["exit_zero", "exit_nonzero", "stdout_contains"] },
 151            "contains": { "type": "string" }
 152          },
 153          "required": ["check", "expect"]
 154        },
 155        {
 156          "properties": {
 157            "type": { "const": "judge" },
 158            "rubric": { "type": "string", "minLength": 1 }
 159          },
 160          "required": ["rubric"]
 161        },
 162        {
 163          "properties": {
 164            "type": { "const": "human" },
 165            "prompt": { "type": "string", "minLength": 1 }
 166          },
 167          "required": ["prompt"]
 168        }
 169      ]
 170    },
 171    "gate": {
 172      "type": "object",
 173      "required": ["when", "members", "verdict_policy", "criteria", "max_revisions"],
 174      "properties": {
 175        "when": { "type": "string" },
 176        "members": {
 177          "type": "array",
 178          "items": { "type": "string" }
 179        },
 180        "verdict_policy": { "enum": ["revise_until_clean", "fixed_passes"] },
 181        "verdict_source": { "type": "string" },
 182        "criteria": {
 183          "type": "array",
 184          "items": { "type": "string" }
 185        },
 186        "max_revisions": { "type": "integer", "minimum": 0 }
 187      }
 188    }
 189  }
 190}

▸templates/loop.yaml — 作者模板

模板里每一段都有 inline 注释解释为什么这样写:argv 数组而不是字符串、"revision_until_clean 必须有 verdict_source"、每个 external invocation 必须有 timeout、cross-vendor egress 必须 redact + consent。模板同时是 schema 的具体例子——所有字段都按 schema 必填选填推下来。

templates/loop.yaml · L1-60Python
   1version: 1
   2meta:
   3  name: ai-workflow-mapping
   4  description: Map a manual workflow into a checkable agent design.
   5  author: ksimback
   6  created: 2026-06-18
   7
   8goal:
   9  statement: >
  10    Produce an agent workflow map that converts the current manual process into
  11    a stepwise agent design with tool calls, handoffs, and human checkpoints.
  12  context_sources:
  13    - file: ./inputs/process-notes.md
  14  definition_of_done: >
  15    LOOP.md and an ASCII flow preview exist, every step has a tool, model, or
  16    human owner, and no step is left as TBD.
  17  verification:
  18    - id: required-sections
  19      type: programmatic
  20      check: ["python", "scripts/check-loop-doc.py", "loop-workspace/delivery-1.md"]
  21      expect: exit_zero
  22    - id: covers-goal
  23      type: judge
  24      rubric: >
  25        Every part of the goal statement is addressed; each workflow step has
  26        an owner; no step depends on information the loop never gathers.
  27    - id: user-signoff
  28      type: human
  29      prompt: "Confirm the workflow map matches the real process."
  30
  31host:
  32  cli: codex
  33  model: gpt-5
  34  invoke: ["codex", "exec", "--model", "gpt-5"]
  35  timeout_sec: 600
  36
  37council:
  38  - id: reviewer-1
  39    role: judge
  40    cli: claude
  41    model: default
  42    invoke: ["claude", "-p"]
  43    timeout_sec: 600
  44    scope: [plan, delivery]
  45    local: false
  46
  47gates:
  48  plan_gate:
  49    when: after_plan
  50    members: [reviewer-1]
  51    verdict_policy: revise_until_clean
  52    verdict_source: reviewer-1
  53    criteria: [covers-goal]
  54    max_revisions: 3
  55  delivery_gate:
  56    when: after_each_delivery
  57    members: [reviewer-1]
  58    verdict_policy: revise_until_clean
  59    verdict_source: reviewer-1
  60    criteria: [required-sections, covers-goal]
§ 02 · KEY FLOW

关键流程

flowchart LR Y["loop.yaml"]:::input L["load_yaml()"]:::stage V["normalize_spec()"]:::stage subgraph V_inner["in-memory mutation"] C["criteria_by_id"]:::map M["council_by_id"]:::map N["normalize each top-level key"]:::map end R["loop.resolved.json"]:::output Z["loop.v1.schema.json"]:::input Y --> L L --> V V --> C V --> M V --> N Z -. validates .-> Y Z -. validates .-> R V --> R classDef input fill:#1a1c22,stroke:#e08a64,color:#f0a585 classDef output fill:#1a1c22,stroke:#a17fe0,color:#b89bf0 classDef stage fill:#1a1c22,stroke:#54d6e0,color:#d6f6fb classDef map fill:#1a1c22,stroke:#7fcf9e,color:#b6e4c8

examples/ai-workflow-mapping/loop.yaml 是唯一公开示例 sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:1——可以看到它和 templates/loop.yaml 的差别:示例里 host=codex exec + reviewer 用 claude -p,每条 verification 都提供了具体 argv 或 rubric,loop_control.usd=5.0 + tokens=2M + wall_clock_min=30 三种预算都打开。这种"具体到能跑"的注释比纯模板更有指导意义。

examples/ai-workflow-mapping/loop.yaml · L1-60Python
   1version: 1
   2meta:
   3  name: ai-workflow-mapping
   4  description: Map a customer's manual workflow into an agent-ready process.
   5  author: ksimback
   6  created: 2026-06-18
   7
   8goal:
   9  statement: >
  10    Produce an agent workflow map that converts the process notes into a
  11    stepwise design with tool calls, model responsibilities, and human
  12    checkpoints.
  13  context_sources:
  14    - file: ./inputs/process-notes.md
  15  definition_of_done: >
  16    A LOOP.md-style workflow map exists, every step has an owner, input,
  17    output, and checkpoint decision where needed, and there are no TBDs.
  18  verification:
  19    - id: required-sections
  20      type: programmatic
  21      check: ["python", "scripts/check-loop-doc.py", "loop-workspace/delivery-1.md"]
  22      expect: exit_zero
  23    - id: covers-goal
  24      type: judge
  25      rubric: >
  26        Every part of the goal statement is addressed. Each workflow step has
  27        an owner, required input, output artifact, and human checkpoint where
  28        business judgment is needed. No step depends on information the loop
  29        never gathers. There are no unresolved TBDs.
  30
  31host:
  32  cli: codex
  33  model: gpt-5
  34  invoke: ["codex", "exec", "--model", "gpt-5"]
  35  timeout_sec: 600
  36
  37council:
  38  - id: reviewer-1
  39    role: judge
  40    cli: claude
  41    model: default
  42    invoke: ["claude", "-p"]
  43    timeout_sec: 600
  44    scope: [plan, delivery]
  45    local: false
  46
  47gates:
  48  plan_gate:
  49    when: after_plan
  50    members: [reviewer-1]
  51    verdict_policy: revise_until_clean
  52    verdict_source: reviewer-1
  53    criteria: [covers-goal]
  54    max_revisions: 3
  55  delivery_gate:
  56    when: after_each_delivery
  57    members: [reviewer-1]
  58    verdict_policy: revise_until_clean
  59    verdict_source: reviewer-1
  60    criteria: [required-sections, covers-goal]

compile flow 落到代码上是 cmd_compile() sources/ksimback-looper/scripts/looper.py:726:load_yaml → normalize_spec → write_json → 视 flags 渲染 LOOP.md 与 RUN_IN_SESSION.md。每份产物都用同一个 resolved dict,所以改一处 spec,三处产物都跟着重构。

▸examples 解析 — verification 与 gates 的实例

  • required-sections / programmatic 跑 scripts/check-loop-doc.py 校验 LOOP.md 关键字段 sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:18;expect=exit_zero。
  • covers-goal / judge 由 reviewer-1(judge 角色,claude -p)按 rubric 评分,给出 pass/revise + blocking_issues sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:23。
  • user-signoff / human 用户在终端 answer=pass 才放进 failures 清空列表 sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:27。
  • gates.plan_gate / delivery_gate 都用 revise_until_clean,max_revisions=3,verdict_source=reviewer-1 sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:47-61。
  • loop_control.no_progress.signals 三条自然语言信号 sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:70-74:same blocking issue repeats / delivery artifact has no material change / verifier output is unchanged —— max_stalled_iterations=2 时停。
  • privacy.egress[reviewer-1] 显式 redact 默认 glob + consent=required,因为 reviewer-1 是非 local sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:96-100。
§ 03 · EDGE CASES

边界 / 失败模式

  • argv 必须是 list 而非 string — sources/ksimback-looper/scripts/looper.py:169 的 normalize_argv 把 string 走 shlex.split(Windows 关闭 POSIX),list 全字符串走原样。任何 scalar 立刻 raise。
  • criterion id 重复 — sources/ksimback-looper/scripts/looper.py:189 在循环里检查 if cid in result: raise LooperError(f"Duplicate verification criterion id: {cid}")。
  • 无 judge 但要求 revise_until_clean — sources/ksimback-looper/scripts/looper.py:248 用 members[source].get("role") != "judge" 拒绝 reviewer 当 judge 用。
  • council / gates 引了不存在的 criterion — sources/ksimback-looper/scripts/looper.py:239 一行 if cid not in criteria 早拒绝。
  • execution.mode 不是 3 选 1 — in_session / external_runner / orchestrated 是 enum,不在白名单就 raise sources/ksimback-looper/scripts/looper.py:344。
  • PyYAML 没装却尝试 compile — load_yaml 第一行就在 except ImportError 里 raise sources/ksimback-looper/scripts/looper.py:66-70。
  • loop.resolved.json 不写回 loop.yaml — output 永远新文件,不污染 authoring source sources/ksimback-looper/scripts/looper.py:730。
  • schema 与 Python 校验双轨 — schema 漏掉的 edge case(argv 字符串化)在 normalize_spec 兜底;normalize_spec 不知的类型 schema 兜底。两轨重叠是健壮性来源。
Sub-page · 2026-07-03 Source · ksimback/looper ↑ looper 主页面