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。
它解决什么,凭什么值得拆
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/(当前 session prompt)。SKILL.md sources/ksimback-looper/SKILL.md:1:79
从源码看,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 抛出。
▸核心数据
sources/ksimback-looper/scripts/looper.py:66 在 load_yaml 强制导入) + stdlib;测试 fixtures 用 fake_host.py / fake_judge.py 模拟 host 与 judge,不依赖真实 CLI深度阅读 → 入口展开。
三段式拓扑: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。
▸系统拓扑
▸模块清单
三段对应的 12 个源码文件 (主入口 sources/ksimback-looper/scripts/looper.py:1,运行端 sources/ksimback-looper/templates/run-loop.py:1),所有运行时都站在 resolved.json 一侧,YAML 只在设计期触碰。
sources/ksimback-looper/references/control-rubric.md 等)——coach 在不同 stage 按需加载,避免一次性塞进上下文。sources/ksimback-looper/examples/ai-workflow-mapping/loop.yaml:37 examples/ai-workflow-mapping/▸关键数据结构 — 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。
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
核心抽象: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 校验)。
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。
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 的纯数据"。
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。
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)
关键流程:compile → resolve → run
最值得拆的是 spec 在三段时间里扮演的不同角色:authoring 时它是带注释的人类可读 YAML;compile 时它是 normalize_spec 的输入;run 时它是被 load_json() 解析的 dict-form。resolved 阶段是 boundary——一次折叠换来两套 runner(外部 Python / 当前 session prompt)共享同一份 truth。
▸compile 流程
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,三处产物都跟着重构。
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)。
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_clocksources/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 分支)"不解析就修"的兜底。
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_issuessources/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 跑飞了怎么办?敏感内容会不会被回传到另一个 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。
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_modelsources/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 让人工翻看。
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 路径爆炸。
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,复盘时一目了然。
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 轮的做法一脉相承:重要路径都要可重复验证。
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_outputsources/ksimback-looper/templates/run-loop.py:112sources/ksimback-looper/templates/run-loop.py:112L112-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;运行期与设计期的依赖面不一致。
技术亮点
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。loop.resolved.json sources/ksimback-looper/scripts/looper.py:393 派生;改 YAML 一处,三处产物一致。sources/ksimback-looper/templates/run-loop.py:131) + blocking_issues;validate_gate 强校验 verdict_source 必须是 judge 角色或 human。技术亮点之外,别忽略这些
源码看下来有意或无意的取舍:
sources/ksimback-looper/templates/run-loop.py:198 同样 signature 才 +1) (sources/ksimback-looper/templates/run-loop.py:198 同一 gate 同样 signature 才 +1)——一种 false negative,可能被无限循环拖死。sources/ksimback-looper/scripts/looper.py:23 默认 glob);如果 secret 被复制到 README、process-notes.md、an inline attachment,runner 不会拦。需要自己加 privacy.egress.redact。sources/ksimback-looper/looper-spec.md:23 把 USD / tokens 列为 operator-acknowledged 的预算,runner 自己不会去解析 CLI 输出统计实际 token 消耗。预算烧穿靠 wall_clock_min 做兜底,精度有限。▸源码引用清单一览
本页所有 source:line 引用都已在 GitHub 源码中核对行号。下面是引用密度最高的 18 个锚点(按文件分组):
sources/ksimback-looper/scripts/looper.py:23 MODEL_PROBESsources/ksimback-looper/scripts/looper.py:60 LooperErrorsources/ksimback-looper/scripts/looper.py:122 run_probesources/ksimback-looper/scripts/looper.py:145 detect_modelssources/ksimback-looper/scripts/looper.py:224 validate_gatesources/ksimback-looper/scripts/looper.py:254 normalize_specsources/ksimback-looper/scripts/looper.py:415 render_ascii_diagramsources/ksimback-looper/scripts/looper.py:726 cmd_compilesources/ksimback-looper/scripts/looper.py:708 cmd_register
sources/ksimback-looper/templates/run-loop.py:101 call_modelsources/ksimback-looper/templates/run-loop.py:112 parse_judge_outputsources/ksimback-looper/templates/run-loop.py:143 Runner.__init__sources/ksimback-looper/templates/run-loop.py:178 enforce_wall_clocksources/ksimback-looper/templates/run-loop.py:188 no_progress_reachedsources/ksimback-looper/templates/run-loop.py:234 redact_prompt_for_membersources/ksimback-looper/templates/run-loop.py:260 ensure_consentsources/ksimback-looper/templates/run-loop.py:451 run_gatesources/ksimback-looper/templates/run-loop.py:542 Runner.run
sources/ksimback-looper/schemas/loop.v1.schema.json:30 verificationsources/ksimback-looper/schemas/loop.v1.schema.json:66 loop_controlsources/ksimback-looper/tests/fixtures/fake_judge.py:11 revise statesources/ksimback-looper/tests/fixtures/fake_host.py:9 prompt switch
sources/ksimback-looper/SKILL.md:1 front-mattersources/ksimback-looper/SKILL.md:54 emit checklistsources/ksimback-looper/looper-spec.md:23 "hard rule"约束