loop.yaml → resolved.json 深度解析
scripts/looper.py 里 normalize_spec 与 validate_gate 的 100+ 行校验逻辑 — 把 50 行 YAML 折叠成 250 行结构化 JSON,为外部 runner 提供单一可信源。
导入链与契约
本子页聚焦 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 跑"。
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"的设计。
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
核心实现,逐段拆
▸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,还 强约束输入形状。
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 分离的核心代码锚点。
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 既能拒绝坏输入,又能给常规模板自动补全。
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 的对应关系,不是类型签名。
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 },
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 必填选填推下来。
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]
关键流程
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 三种预算都打开。这种"具体到能跑"的注释比纯模板更有指导意义。
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。
边界 / 失败模式
- 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里 raisesources/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 兜底。两轨重叠是健壮性来源。