
Improvement Orchestrator
- 1 installs
- 6 repo stars
- Updated April 13, 2026
- lanyasheng/auto-improvement-orchestrator-skill
Runs the full generate-score-evaluate-execute-gate skill-improvement pipeline end to end, auto-retrying failed rounds by injecting failure traces.
About
Coordinates the five-stage improvement pipeline (Generator, Discriminator, Evaluator, Executor, Gate) for one or more skills. A developer uses it to run a complete improvement cycle with trace-aware retries rather than invoking each stage manually.
- End-to-end pipeline with a Ralph Wiggum retry loop (max 3) that injects failure traces
- Evaluator stage is skipped when no task_suite.yaml exists
Improvement Orchestrator by the numbers
- 1 all-time installs (skills.sh)
- Ranked #642 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lanyasheng/auto-improvement-orchestrator-skill --skill improvement-orchestratorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | April 13, 2026 |
| Repository | lanyasheng/auto-improvement-orchestrator-skill ↗ |
What it does
Runs the full generate-score-evaluate-execute-gate skill-improvement pipeline end to end, auto-retrying failed rounds by injecting failure traces.
Files
Improvement Orchestrator
Coordinates the full improvement pipeline: Generator → Discriminator → Evaluator → Executor → Gate.
When to Use
- Run a full improvement cycle on one or more skills
- Coordinate the 5-stage pipeline end-to-end (with optional evaluator)
- Retry failed improvements with trace-aware feedback (Ralph Wiggum loop)
When NOT to Use
- 只想检查 skill 质量评分 → use
improvement-learner - 只想手动给候选打分 → use
improvement-discriminator - 只想改一个文件 → use
improvement-executor - 只想查基准数据 → use
benchmark-store
Pipeline
propose → discriminate → evaluate* → execute → gate
↻ Ralph Wiggum: fail → inject trace → retry (max 3)
* evaluate is optional — skipped if no task_suite.yaml exists<example> 正确用法: 对一个 skill 运行全流程改进 $ python3 scripts/orchestrate.py --target /path/to/skill --state-root ./state → 自动完成: 生成候选 → 多人盲审 → 任务评估 → 执行变更 → 6层门禁 → 失败时自动注入 trace 重试(最多 3 次) </example>
<anti-example> 错误用法: 只想看评分却用了 orchestrator $ python3 scripts/orchestrate.py --target /path/to/skill # 会执行变更! → 应该用: python3 improvement-learner/scripts/self_improve.py --skill-path /path/to/skill --max-iterations 1 </anti-example>
CLI
python3 scripts/orchestrate.py \
--target /path/to/skill \
--state-root /path/to/state \
--max-retries 3 \
--autoOutput Artifacts
| Request | Deliverable |
|---|---|
| Full pipeline | JSON with all stage outputs, final scores, execution trace |
| Retry cycle | Updated candidates with injected failure traces |
Related Skills
- improvement-generator: Produces candidate proposals (stage 1)
- improvement-discriminator: Multi-reviewer panel scoring (stage 2)
- improvement-evaluator: Task suite execution validation (stage 3, optional)
- improvement-executor: Applies changes with backup/rollback (stage 4)
- improvement-gate: 6-layer quality gate (stage 5)
- benchmark-store: Frozen benchmarks and Pareto front data
References
- Architecture — System design and data flow
- Guardrails — Safety rules and protected targets
- End-to-End Demo — Complete walkthrough
[
{
"type": "coverage",
"succeeded": true,
"context": {
"dimension": "coverage",
"scores": {
"coverage": 1.0,
"accuracy": 0.8,
"efficiency": 1.0,
"reliability": 1.0,
"security": 0.8
}
},
"timestamp": "2026-04-02T12:14:16Z",
"hit_count": 1
},
{
"type": "instruction",
"succeeded": true,
"context": {
"dimension": "accuracy",
"scores": {
"coverage": 1.0,
"accuracy": 0.8,
"efficiency": 1.0,
"reliability": 1.0,
"security": 0.8
}
},
"timestamp": "2026-04-02T12:14:17Z",
"hit_count": 1
}
]
improvement-orchestrator
Auto-generated README for improvement-orchestrator skill.
Adapters — 各 Lane 适配器规格
版本: v0.1 状态: Design Draft
---
概述
Adapter 层负责将统一的四角色流程适配到不同对象类型:
┌─────────────────────────────────────────────────────────────┐
│ Auto-Improvement Orchestrator │
│ (Proposer → Critic → Executor → Gate) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────┼─────────────────────┐
↓ ↓ ↓
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Skill Adapter │ │ Macro Adapter │ │ Browser Adapter│
└───────────────┘ └───────────────┘ └───────────────┘
↓ ↓ ↓
skill files macro config browser workflow---
Lane 定义
| Lane | 对象 | 典型文件 | 评估方式 |
|---|---|---|---|
generic-skill | OpenClaw skill | SKILL.md, scripts/ | skill-evaluator |
skill-evaluator | skill-evaluator 自身 | SKILL.md, tests/ | frozen benchmark |
macro | macro/ainews 配置 | config.yaml, rules/ | 推送准确率 |
browser-workflow | browser ops | selectors.json, flows/ | 抓取成功率 |
---
Skill Adapter
目标对象
~/.openclaw/skills/<skill-name>/~/.openclaw/workspace/skills/<skill-name>/
Phase 1 minimal integration(已实现)
当前 generic-skill lane 的 Critic 已接入轻量级 evaluator evidence adapter:
- 引用
skill-evaluator的 rubric weights / category mapping / level boundary - 输出
evaluator_evidence、score_components、evaluator_score - 未真正执行 promptfoo / frozen benchmark / hidden tests / external regression
属于 rubric-assisted judge,不是 full evaluator runtime。
Full Adapter 规格(Phase 2 规划)
详见 references/skill-evaluator-adapter.md,包含:
- frozen benchmark 执行规格
- hidden tests 执行规格
- external regression callback
- human spot-check interface
修改方式
- SKILL.md:
read/edit/write - scripts/: git-based patch
回滚方式
git checkout <commit> -- skills/<skill-name>/---
Macro Adapter
目标对象
~/.openclaw/skills/news-aggregator-skill/~/.openclaw/skills/x-hot-topics-daily/
评估方法
class MacroAdapter:
def check_push_accuracy(self, config_path):
# 对比推送内容与实际热点
pass
def check_source_health(self, config_path):
# 检查数据源可用性
pass修改方式
- config.yaml:
read/edit/write(结构化文件禁止 heredoc) - rules/: 原子替换
回滚方式
git checkout <commit> -- skills/news-aggregator-skill/---
Browser Adapter
目标对象
- browser workflow 配置
- selector 文件
- 反爬策略配置
评估方法
class BrowserAdapter:
def check_selector_health(self, selector_path):
# 测试 selector 是否仍然有效
pass
def check_success_rate(self, workflow_path):
# 统计抓取成功率
pass修改方式
- selectors.json:
read/edit/write - flows/: 版本化更新
回滚方式
git checkout <commit> -- browser/---
Adapter 接口规范
所有 Adapter 必须实现以下接口:
class BaseAdapter:
def validate(self, target_path) -> bool:
"""验证目标对象是否有效"""
pass
def evaluate(self, target_path) -> dict:
"""运行评估,返回评分和报告"""
pass
def apply_change(self, target_path, change_spec) -> dict:
"""应用修改,返回结果"""
pass
def rollback(self, target_path, checkpoint) -> bool:
"""回滚到指定 checkpoint"""
pass---
Phase 实现计划
| Phase | Lane | 状态 |
|---|---|---|
| Phase 1 | generic-skill | ✅ first runnable + rubric-assisted critic |
| Phase 2 | skill-evaluator | 🟡 full evaluator runtime 待接 |
| Phase 2 | macro | 📝 规划 |
| Phase 3 | browser-workflow | 📝 规划 |
---
注意事项
1. 不要硬编码路径:Adapter 应支持配置化路径 2. 原子操作:修改必须原子,避免中间状态 3. 可追溯:所有修改必须记录 diff 4. 可回滚:必须支持 rollback
Architecture — generic-skill lane(first runnable version)
版本: v0.2 状态: generic-skill lane 已可运行;其他 lane 仍以规划为主
---
1. 总览
当前实现采用五角色顺序推进(与 SKILL.md 一致):
Generator -> Discriminator -> Evaluator -> Executor -> Gate但重点不只是脚本能跑,而是每一步都会落本地 artifact,形成可被后续 control plane 消费的状态推进链。
---
2. 目录真值
generic-skill lane 的 artifact 根目录:
$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/
├── candidate_versions/ # proposer 输出
├── rankings/ # critic 输出
├── executions/ # executor 输出 + backups/
├── receipts/ # gate receipt
└── state/
├── current_state.json
├── pending_promote.json
├── veto.json
└── last_run.json这套目录就是当前版本的本地 control-plane-friendly 状态面。
---
3. 各角色当前实现边界
3.1 Proposer
输入:
--target- 可选
--source(支持 memory / learnings /.feedback的简单文本读取)
行为:
- 分析目标 skill/file 的 Markdown 结构
- 读取输入来源中的关键词/信号
- 生成 candidate 列表
当前能提出的 candidate 类型:
docsreferenceguardrailpromptworkflowtests
说明:
docs/reference/guardrail默认视为低风险优先候选prompt/workflow/tests当前只进入候选池,不等于会自动执行
3.2 Critic
当前不是 LLM judge,而是规则 + 启发式评分器:
- 分类加权(docs/reference/guardrail 更高)
- 风险惩罚(low / medium / high)
- protected target 惩罚
- executor 是否支持 的惩罚/加分
输出:
scorerisk_penaltyrecommendationblockersjudge_notes
recommendation 当前三档:
accept_for_executionholdreject
3.3 Executor
当前只支持非常保守的执行面:
- 低风险 Markdown 文案追加
- action 主要是
append_markdown_section
支持类别:
docsreferenceguardrail
不支持:
promptworkflowtests- 任意复杂逻辑修改
执行结果会产出:
statusmodifieddiffdiff_summarybackup_pathrollback_pointer
3.4 Gate
Gate 读取 critic + executor 输出后做最终决策:
keeppending_promoterevertreject
当前保守策略:
- 只有
docs/reference/guardrail+low risk+ 执行成功,才允许keep hold候选进入pending_promotereject候选直接进入reject/revert- 如果有已修改文件但不允许保留,优先恢复 backup
---
4. 状态模型
4.1 stage 字段
当前产物统一使用以下 stage:
proposedrankedexecutedgated
状态文件内部的流程 stage:
proposedrankedexecutedgated_keepgated_pendinggated_revertgated_reject
4.2 control-plane-friendly 字段
所有关键 artifact / state 文件都尽量包含:
stagestatusnext_stepnext_ownertruth_anchorrun_id
这几个字段是为了后续接 orchestration/control-plane,而不是只服务人类阅读。
---
5. 状态文件职责
current_state.json
当前真值锚点,描述:
- 当前 run
- 当前 stage/status
- 下一步应该由谁接手
- 当前 truth anchor 在哪里
last_run.json
最新一次处理结果摘要,便于快速读取当前 lane 最近落点。
pending_promote.json
存放 hold -> pending_promote 的候选,给后续人审或控制面晋升使用。
veto.json
存放被 reject/revert 的候选和原因,形成 veto 记忆面。
---
6. 为什么说它已接入“自动编排推进思路”
当前版本还没有真正改 canonical orchestration repo,但已经对齐了关键编排约束:
1. 阶段推进真实存在
- proposer / critic / executor / gate 各自产生 artifact
2. 状态推进真实存在
current_state.json/last_run.json每步都更新
3. 待晋升/否决真实存在
pending_promote.json/veto.json不是纸面字段,而是可写状态文件
4. 真值锚点可追踪
- 每个阶段都能从
truth_anchor找回对应 artifact
5. 后续控制面可读
- machine-readable JSON 优先,不把状态藏在聊天文本里
---
7. 当前仍未实现
- 冻结 benchmark / hidden tests
- richer adapter 协议
- 多 lane 共用调度器
- 并发锁 / run arbitration
- skill-evaluator 真正 judge 化接入
- 审批总线 / Discord review hook
换句话说,当前版本是:
generic-skill lane 已经能跑,而且产物结构已经对齐 control-plane;但“更智能的 judge”和“更完整的编排控制面”还没接上。
End-to-End Demo — generic-skill lane 完整示例
版本: v0.1 状态: 基于 first runnable version 的真实执行记录
---
概述
本文档演示 generic-skill lane 一次完整的四角色流程:
Proposer → Critic → Executor → Gate包含每一步的输入/输出样例、artifact 路径、以及不同 gate 决策的差异。
---
场景设定
目标: 对 newsletter-creation-curation skill 进行自动改进
输入来源: 用户反馈文件 $OPENCLAW_ROOT/shared-context/intel/auto-improvement/demo-feedback/newsletter-creation-curation-demo.feedback.md
反馈内容摘要:
- 缺少行业特定模板
- 没有自动化工作流示例
- 需要添加 cadence 建议
---
Step 1: Proposer
执行命令
python scripts/propose_candidate.py \
--target $OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation \
--source $OPENCLAW_ROOT/shared-context/intel/auto-improvement/demo-feedback/newsletter-creation-curation-demo.feedback.md输入
目标路径: $OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation
反馈来源:
# 用户反馈
## 问题
1. 缺少行业特定模板(科技/金融/教育)
2. 没有自动化工作流示例
3. 需要添加 cadence 建议
## 期望
- 增加 3 个行业模板
- 补充 Zapier/Make/n8n 集成示例
- 添加发布频率建议输出(Candidate Artifact)
路径: $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/candidate_versions/run-20260401-143022.json
内容示例:
{
"run_id": "run-20260401-143022",
"timestamp": "2026-04-01T14:30:22+08:00",
"stage": "proposer_complete",
"status": "success",
"next_step": "critic",
"next_owner": "run_critic.py",
"truth_anchor": {
"target_path": "$OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation",
"source_path": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/demo-feedback/newsletter-creation-curation-demo.feedback.md"
},
"candidates": [
{
"id": "cand-001",
"title": "添加行业特定模板章节",
"target_path": "$OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation/SKILL.md",
"category": "reference",
"rationale": "用户反馈缺少行业模板,补充后可提升技能实用性",
"risk_level": "low",
"proposed_change_summary": "在 SKILL.md 中新增'行业模板'章节,包含科技/金融/教育三个行业的 newsletter 模板示例",
"change_type": "append_markdown_section",
"section_title": "## 行业模板示例",
"section_content": "..."
},
{
"id": "cand-002",
"title": "补充自动化工作流集成",
"target_path": "$OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation/references/workflows.md",
"category": "reference",
"rationale": "用户需要 Zapier/Make/n8n 集成示例",
"risk_level": "low",
"proposed_change_summary": "新增 workflows.md 文件,描述三种自动化工具的集成步骤",
"change_type": "create_file",
"file_content": "..."
},
{
"id": "cand-003",
"title": "添加发布频率建议",
"target_path": "$OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation/SKILL.md",
"category": "docs",
"rationale": "用户需要 cadence 建议",
"risk_level": "low",
"proposed_change_summary": "在 SKILL.md 中新增'发布频率建议'章节",
"change_type": "append_markdown_section",
"section_title": "## 发布频率建议",
"section_content": "..."
}
]
}---
Step 2: Critic
执行命令
python scripts/run_critic.py \
--input $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/candidate_versions/run-20260401-143022.json \
--use-evaluator-evidence输入
读取上一步生成的 candidate artifact。
输出(Ranking Artifact)
路径: $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/rankings/run-20260401-143022.json
内容示例:
{
"run_id": "run-20260401-143022",
"timestamp": "2026-04-01T14:31:45+08:00",
"stage": "critic_complete",
"status": "success",
"next_step": "executor",
"next_owner": "run_executor.py",
"critic_mode": "rubric_assisted",
"ranked_candidates": [
{
"id": "cand-001",
"rank": 1,
"score": 0.85,
"score_components": {
"evaluator_score": 0.88,
"heuristic_score": 0.82,
"final_score": 0.85
},
"evaluator_evidence": {
"rubric": {"clarity": 0.3, "correctness": 0.4, "maintainability": 0.3},
"category": "reference",
"boundary": "low_risk",
"test_tracks": [],
"limitations": ["未运行 frozen benchmark", "未执行 hidden tests"]
},
"risk_penalty": 0.0,
"recommendation": "accept_for_execution",
"reasoning": "低风险文档类修改,直接解决用户反馈的核心问题"
},
{
"id": "cand-002",
"rank": 2,
"score": 0.78,
"score_components": {
"evaluator_score": 0.80,
"heuristic_score": 0.76,
"final_score": 0.78
},
"evaluator_evidence": {
"rubric": {"clarity": 0.3, "correctness": 0.4, "maintainability": 0.3},
"category": "reference",
"boundary": "low_risk",
"test_tracks": [],
"limitations": ["未运行 frozen benchmark", "未执行 hidden tests"]
},
"risk_penalty": 0.0,
"recommendation": "accept_for_execution",
"reasoning": "创建新文件,低风险,补充工作流示例"
},
{
"id": "cand-003",
"rank": 3,
"score": 0.72,
"score_components": {
"evaluator_score": 0.75,
"heuristic_score": 0.69,
"final_score": 0.72
},
"evaluator_evidence": {
"rubric": {"clarity": 0.3, "correctness": 0.4, "maintainability": 0.3},
"category": "docs",
"boundary": "low_risk",
"test_tracks": [],
"limitations": ["未运行 frozen benchmark", "未执行 hidden tests"]
},
"risk_penalty": 0.0,
"recommendation": "hold",
"reasoning": "有价值但优先级较低,建议先完成前两个候选"
}
]
}---
Step 3: Executor
执行命令
python scripts/run_executor.py \
--input $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/rankings/run-20260401-143022.json \
--candidate-id cand-001输入
读取 ranking artifact 和指定的 candidate ID。
输出(Execution Artifact)
路径: $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/executions/run-20260401-143022-cand-001.json
内容示例:
{
"run_id": "run-20260401-143022",
"candidate_id": "cand-001",
"timestamp": "2026-04-01T14:32:30+08:00",
"stage": "executor_complete",
"status": "success",
"next_step": "gate",
"next_owner": "apply_gate.py",
"truth_anchor": {
"target_path": "$OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation/SKILL.md",
"ranking_path": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/rankings/run-20260401-143022.json"
},
"execution_result": {
"action": "append_markdown_section",
"success": true,
"changes_made": true,
"lines_added": 25,
"section_title": "## 行业模板示例"
},
"diff_summary": {
"unified_diff": "@@ -45,6 +45,31 @@\n ...\n+## 行业模板示例\n+\n+### 科技行业\n+...\n+### 金融行业\n+...\n+### 教育行业\n+...",
"files_modified": [
"$OPENCLAW_ROOT/workspace/skills/newsletter-creation-curation/SKILL.md"
]
},
"backup": {
"backup_path": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/executions/backups/run-20260401-143022/SKILL.md.bak",
"rollback_pointer": "backup-run-20260401-143022-cand-001"
}
}---
Step 4: Gate
执行命令
python scripts/apply_gate.py \
--ranking $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/rankings/run-20260401-143022.json \
--execution $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/executions/run-20260401-143022-cand-001.json输入
读取 ranking 和 execution artifact。
输出(Gate Receipt)
路径: $OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/receipts/run-20260401-143022-cand-001-gate.json
内容示例(keep 决策):
{
"run_id": "run-20260401-143022",
"candidate_id": "cand-001",
"timestamp": "2026-04-01T14:33:00+08:00",
"stage": "gate_complete",
"status": "success",
"decision": "keep",
"reasoning": "低风险文档类修改,执行成功,符合 auto-keep 条件",
"truth_anchor": {
"ranking_path": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/rankings/run-20260401-143022.json",
"execution_path": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/executions/run-20260401-143022-cand-001.json"
},
"next_step": "none",
"next_owner": "user_or_control_plane",
"artifact_paths": {
"candidate": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/candidate_versions/run-20260401-143022.json",
"ranking": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/rankings/run-20260401-143022.json",
"execution": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/executions/run-20260401-143022-cand-001.json",
"receipt": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/receipts/run-20260401-143022-cand-001-gate.json"
}
}---
Gate 决策差异
keep(保留)
条件:
- low-risk + docs/reference/guardrail 类别
- 执行成功
- 符合 auto-keep 策略
结果:
- 修改保留
- 写入 receipt
- 状态标记为
completed
pending_promote(待晋升)
条件:
- Critic recommendation =
hold - 或 medium-risk 需要人工审查
- 或有价值但当前不自动执行
结果:
- 修改可能已应用但不自动 keep
- 写入
state/pending_promote.json - 等待控制面或人工晋升
示例:
{
"decision": "pending_promote",
"reasoning": "Critic 推荐 hold,值得保留但需要更强 judge 或人工确认",
"pending_path": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/state/pending_promote.json"
}reject(否决)
条件:
- Critic recommendation =
reject - 或收益不足/风险过高
- 或未修改文件
结果:
- 无修改或修改已回滚
- 写入
state/veto.json - 候选被否决
示例:
{
"decision": "reject",
"reasoning": "收益不足,风险过高",
"veto_path": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/state/veto.json"
}revert(回滚)
条件:
- Critic recommendation =
reject但已修改文件 - 或执行失败但产生中间状态
- 或 Gate 不允许保留已修改文件
结果:
- 使用 backup 恢复目标文件
- 写入 receipt 标记为 reverted
- 写入
state/veto.json
示例:
{
"decision": "revert",
"reasoning": "Critic 拒绝但已修改文件,执行回滚",
"rollback_used": true,
"backup_restored": "$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/executions/backups/run-20260401-143022/SKILL.md.bak"
}---
状态文件更新
执行完成后,以下状态文件会被更新:
state/current_state.json
{
"last_run_id": "run-20260401-143022",
"last_update": "2026-04-01T14:33:00+08:00",
"stage": "gate_complete",
"status": "success",
"lane": "generic-skill",
"total_candidates": 3,
"kept": 1,
"pending": 1,
"rejected": 1
}state/last_run.json
{
"run_id": "run-20260401-143022",
"start_time": "2026-04-01T14:30:22+08:00",
"end_time": "2026-04-01T14:33:00+08:00",
"duration_seconds": 158,
"artifacts_generated": 4,
"decisions": {
"cand-001": "keep",
"cand-002": "pending_promote",
"cand-003": "reject"
}
}---
完整文件树(执行后)
$OPENCLAW_ROOT/shared-context/intel/auto-improvement/generic-skill/
├── candidate_versions/
│ └── run-20260401-143022.json
├── rankings/
│ └── run-20260401-143022.json
├── executions/
│ ├── run-20260401-143022-cand-001.json
│ └── backups/
│ └── run-20260401-143022/
│ └── SKILL.md.bak
├── receipts/
│ └── run-20260401-143022-cand-001-gate.json
└── state/
├── current_state.json
├── pending_promote.json
├── veto.json
└── last_run.json---
注意事项
1. 所有 artifact 都是 machine-readable
- 控制面可直接解析 JSON
- 不依赖聊天记录
2. truth_anchor 字段
- 每个 artifact 都包含关键路径引用
- 便于追溯和调试
3. next_step / next_owner
- 明确下一步动作和执行者
- 支持控制面自动推进
4. 保守策略
- 当前仅 auto-keep 低风险文档修改
- 其他候选进入 pending/reject
Guardrails — generic-skill lane(first runnable version)
版本: v0.2 状态: 第一版 guardrails 已落到可运行脚本
---
1. 当前 guardrail 目标
第一版不是追求“自动改一切”,而是确保:
1. 只放行低风险候选自动执行 2. 每次修改都能追溯 3. 不满足 keep 条件时能进入:
pending_promoterejectrevert
4. 已修改文件在必要时能恢复 backup
---
2. Executor 当前允许什么
允许自动执行
docsreferenceguardrail
且当前仅支持:
- Markdown 文案追加(
append_markdown_section)
明确不自动执行
promptworkflowtests- 任意代码/复杂逻辑变更
- 删除文件
- 对外契约调整
如果 candidate 属于不支持类别,Executor 必须返回:
unsupported
而不是伪造成功。
---
3. Critic 推荐与 Gate 决策矩阵
Critic recommendation
| recommendation | 含义 |
|---|---|
accept_for_execution | 低风险且值得立即执行 |
hold | 有价值,但当前版本不应自动 keep |
reject | 收益不足或风险过高 |
Gate decision
| 条件 | Gate 输出 |
|---|---|
| low-risk + docs/reference/guardrail + execution success/no_change | keep |
critic = hold | pending_promote |
critic = reject 且未改文件 | reject |
critic = reject 且已改文件 | revert |
| execution failed / gate 不允许保留已改文件 | revert |
说明:
hold是“保留候选,等待更强 judge 或人工晋升”reject是“当前直接否决”revert是“工作树里已经有改动,因此先回滚”
---
4. Rollback 机制(当前已实现)
执行前:
- 先把目标文件 copy 到
executions/backups/<run-id>/...
执行后:
- execution artifact 会写入:
backup_pathrollback_pointer
Gate 若需要撤销:
- 使用 backup 恢复目标文件
当前第一版优先走 backup restore,而不是强绑定 git-only rollback。
---
5. 持久化风控状态
pending_promote.json
记录需要后续人工/控制面晋升的候选:
run_idcandidate_idcategorytarget_pathrecommendationreceipt_path
veto.json
记录被 reject/revert 的候选:
run_idcandidate_iddecisionreasonreceipt_path
这两份文件就是第一版“自动编排推进能力”里的保守分流面。
---
6. 红线(第一版)
以下情况默认不 auto-keep:
1. prompt / workflow / tests / 代码类候选 2. 任何 medium/high risk 候选 3. protected target 4. 删除、重写、大段结构性改造 5. 修改对外行为/契约
---
7. 为什么当前默认保守
因为这一版的目标是:
先把状态推进、候选排序、执行记录、回滚指针都做成真实 artifact,
再逐步扩 executor/judge 能力。
所以当前策略是:
- 自动 keep 很窄
- pending/reject/revert 很明确
- 不要用模糊“人工看看”替代状态文件
---
8. 后续升级方向
下一阶段 guardrails 会补:
- frozen benchmark
- hidden tests
- richer protected target policy
- human approval receipt / review bus
- skill-evaluator adapter 证据接入
Phases — 路线图与里程碑
版本: v0.3 状态: generic-skill P0 已从 skeleton 推进到 first runnable version,并接入 Phase 1 minimal evaluator evidence
---
Phase 1: generic-skill first runnable version(已完成 P0)
目标
把 generic-skill lane 从“能讲设计”推进到“能真实跑 proposer → critic → executor → gate”。
当前已完成
- [x]
propose.py:可读目标路径 + 简单 feedback 来源,输出 candidate artifact - [x]
score.py:可对 candidate 打分、排序、给 recommendation - [x]
execute.py:可执行低风险 Markdown 文案追加,产出 diff + backup - [x]
gate.py:可输出keep / pending_promote / revert / reject - [x] 持久化状态目录:
candidate_versions / rankings / executions / receipts / state - [x] 状态文件:
current_state.json / pending_promote.json / veto.json / last_run.json - [x] 输出字段对齐 control-plane-friendly 结构:
stage / status / next_step / next_owner / truth_anchor - [x] 已完成低风险目标 skill 的端到端演示
当前已知边界
- 只自动执行
docs/reference/guardrail - 只支持简单 Markdown 追加
prompt/workflow/tests仍以hold/reject为主- 已有 Phase 1 minimal evaluator evidence adapter,但还没有真正跑 benchmark / hidden tests / external regression
Phase 1 成功标准(已达成)
- 能真实生成 candidate/ranking/execution/gate receipt
- 能写状态文件而不是只在聊天里描述状态
- 能保守地 keep 低风险文档修改
- 能把非自动 keep 的候选分流到 pending/reject
---
Phase 2: richer judge + safer auto-promotion(下一步)
目标
把当前“纯规则 critic”升级成“规则 + evaluator 证据”的混合判断。
计划交付
- [x] Phase 1 minimal integration:把
skill-evaluator的 rubric / category / boundary 证据接入 Critic 输出与混合打分 - [ ] full
skill-evaluatoradapter(真实调用 evaluator CLI / benchmark / hidden tests) - [ ] 更细粒度 protected target policy
- [ ] docs/reference 之外的更多低风险动作
- [ ] 基础 smoke checks / benchmark hook
- [ ] pending_promote 的人工晋升接口
成功标准
hold和reject的区别更稳定- low-risk 文档候选的 keep 更可信
- Critic 输出包含可被后续控制面消费的 evaluator/evidence 字段
- pending_promote 能被后续控制面继续消费,而不是停在 JSON 文件
---
Phase 3: multi-lane orchestration 接入
目标
把当前 generic-skill lane artifact 接上更完整 orchestration/control-plane。
计划交付
- [ ] macro lane 接口统一
- [ ] browser-workflow lane 接口统一
- [ ] lane 级 run arbitration / 锁
- [ ] pending/veto 统一查看面板
- [ ] 外部审批/回调通道
成功标准
- 多 lane 共用统一 artifact 协议
- 控制面可基于
truth_anchor+next_owner自动推进 - 不再依赖人工到处翻聊天记录找状态
---
当前结论
现在的 generic-skill lane 已经不是纸面设计:
它已经有真实候选、真实排序、真实执行、真实 gate 与真实状态文件。
但它仍然是保守的第一版:
- 先把推进面做实
- 再把 judge 能力做强
- 最后再扩复杂自动修改面
Skill-Evaluator Adapter — 规格与边界
版本: v0.1 状态: Phase 1 minimal integration 已完成,full adapter 规划中
---
概述
本文档集中描述 skill-evaluator adapter 的当前实现状态与未来规划,避免相关描述分散在多个文件中。
---
Phase 1 Minimal Integration(已实现)
当前 generic-skill lane 的 Critic 已接入一层轻量级 evaluator evidence adapter:
已接线能力
1. Rubric 证据引用
- 读取
skill-evaluator定义的评估维度权重 - 将 rubric 评分映射到 Critic 的
score_components
2. Category Mapping
- 根据 candidate 的
category字段(docs/reference/guardrail/prompt/workflow/tests) - 应用不同的风险边界和评分策略
3. Boundary Evidence
- 输出
evaluator_evidence字段,包含: rubric: 评估维度与权重category: 候选类别boundary: 风险等级边界test_tracks: 预留的测试轨道(当前为空)limitations: 当前评估的局限性说明
4. 混合打分
evaluator_score: 基于 rubric 的证据分heuristic_score: 基于规则的启发式分final_score: 加权混合(当前权重可配置)
调用方式
python scripts/run_critic.py \
--input <candidate.json> \
--use-evaluator-evidence输出示例
{
"critic_mode": "rubric_assisted",
"evaluator_evidence": {
"rubric": {"clarity": 0.3, "correctness": 0.4, "maintainability": 0.3},
"category": "reference",
"boundary": "low_risk",
"test_tracks": [],
"limitations": ["未运行 frozen benchmark", "未执行 hidden tests"]
},
"score_components": {
"evaluator_score": 0.75,
"heuristic_score": 0.70,
"final_score": 0.73
}
}---
Full Adapter 规划(Phase 2)
未来 skill-evaluator full adapter 需要补充以下能力:
1. Frozen Benchmark 执行
class SkillEvaluatorAdapter:
def run_frozen_benchmark(self, skill_path: str) -> dict:
"""
运行预定义的 frozen benchmark suite
返回:
- benchmark_name
- total_tests
- passed_tests
- failure_details
- regression_detected (bool)
"""
pass要求:
- Benchmark 必须版本化(frozen)
- 支持增量运行(只跑受影响测试)
- 输出 machine-readable 结果
2. Hidden Tests 执行
def run_hidden_tests(self, skill_path: str, proposer_visible: bool = False) -> dict:
"""
运行 Proposer 不可见的 hidden tests
参数:
- proposer_visible: 是否对 Proposer 可见(默认 False)
返回:
- test_suite_name
- results (pass/fail/skip)
- coverage_delta
"""
pass边界:
- Hidden tests 对 Proposer 不可见,防止 overfitting
- 仅 Gate / 控制面可访问结果
- 支持回归检测
3. External Regression Callback
def check_external_regression(self, skill_path: str, change_spec: dict) -> dict:
"""
检查对外部系统的回归影响
返回:
- affected_apis
- affected_workflows
- regression_risk (low/medium/high)
- mitigation_required (bool)
"""
pass4. Human Spot-Check Interface
def request_human_review(self, candidate: dict, reason: str) -> str:
"""
请求人工抽查,返回 review ticket ID
返回:
- review_ticket_id
- status (pending/approved/rejected)
"""
pass使用场景:
- 高风险修改(代码/核心逻辑)
- 边界情况(medium risk + 复杂变更)
- 自动化评估置信度低
---
评估边界定义
Frozen Benchmark
| 项目 | 当前状态 | Phase 2 目标 |
|---|---|---|
| 测试套件定义 | ❌ 未定义 | ✅ 每个 skill 至少 3 个核心场景测试 |
| 版本锁定 | ❌ 未实现 | ✅ git-tagged benchmark versions |
| 自动执行 | ❌ 未接入 | ✅ Critic 自动调用 |
| 回归检测 | ❌ 未实现 | ✅ 与历史版本对比 |
Hidden Tests
| 项目 | 当前状态 | Phase 2 目标 |
|---|---|---|
| 测试生成 | ❌ 未定义 | ✅ 基于 skill 描述自动生成 |
| 隔离执行 | ❌ 未实现 | ✅ 独立于 Proposer 视野 |
| 结果消费 | ❌ 未接入 | ✅ Gate 决策依据之一 |
External Regression
| 项目 | 当前状态 | Phase 2 目标 |
|---|---|---|
| 依赖图谱 | ❌ 未建立 | ✅ skill 间依赖关系图 |
| 影响分析 | ❌ 未实现 | ✅ 变更传播分析 |
| 回调机制 | ❌ 未实现 | ✅ 受影响 skill 自动重测 |
Human Spot-Check
| 项目 | 当前状态 | Phase 2 目标 |
|---|---|---|
| 触发条件 | ⚠️ 仅规则判断 | ✅ 基于风险评分 + 置信度 |
| 工单系统 | ❌ 未建立 | ✅ review ticket 追踪 |
| 反馈闭环 | ❌ 未实现 | ✅ 人工结论回流到 evaluator |
---
与 Critic 的集成方式
Phase 1(当前)
Proposer → Critic(heuristic + rubric evidence) → Executor → Gate- Critic 使用
evaluator_phase1.py生成证据 - 不执行真实测试,仅引用 rubric/category/boundary
Phase 2(规划)
Proposer → Critic(heuristic + evaluator runtime) → Executor → Gate
↓
run_frozen_benchmark()
run_hidden_tests()
check_external_regression()
request_human_review() [if needed]- Critic 调用完整的
skill-evaluatoradapter - 真实执行测试和回归检查
- Gate 决策基于更丰富的证据
---
注意事项
1. 不要混淆 Phase 1 和 Phase 2
- Phase 1 是 rubric-assisted judge
- Phase 2 才是 full evaluator runtime
2. 保持向后兼容
--use-evaluator-evidence标志保持可选- 默认模式仍是纯 heuristic
3. 测试优先
- Full adapter 落地前必须先定义 frozen benchmark
- 没有测试的自动修改默认
hold或reject
---
相关文件
adapters.md: Adapter 总体规格与导航phases.md: 路线图与里程碑guardrails.md: 风控策略与边界
#!/usr/bin/env python3
"""Orchestrator dispatch for the auto-improvement pipeline.
Coordinates the full PROPOSE → DISCRIMINATE → EVALUATE → EXECUTE → GATE loop
with Ralph Wiggum-style retry: on revert, capture a structured failure trace
and feed it back into the next proposal round.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
REPO_ROOT = _REPO_ROOT
from lib.common import read_json, utc_now_iso, write_json
# ---------------------------------------------------------------------------
# Script paths (relative to repo root)
# ---------------------------------------------------------------------------
GENERATOR_SCRIPT = REPO_ROOT / "skills" / "improvement-generator" / "scripts" / "propose.py"
DISCRIMINATOR_SCRIPT = REPO_ROOT / "skills" / "improvement-discriminator" / "scripts" / "score.py"
EVALUATOR_SCRIPT = REPO_ROOT / "skills" / "improvement-evaluator" / "scripts" / "evaluate.py"
EXECUTOR_SCRIPT = REPO_ROOT / "skills" / "improvement-executor" / "scripts" / "execute.py"
GATE_SCRIPT = REPO_ROOT / "skills" / "improvement-gate" / "scripts" / "gate.py"
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Orchestrate the full auto-improvement pipeline",
)
parser.add_argument("--target", required=True, help="Target skill/file path")
parser.add_argument(
"--source",
action="append",
default=[],
help="Feedback/memory source (repeatable)",
)
parser.add_argument("--state-root", required=True, help="State directory root")
parser.add_argument(
"--max-retries",
type=int,
default=3,
help="Max retry attempts on revert (default: 3)",
)
parser.add_argument(
"--auto",
action="store_true",
help="Run full pipeline without pausing",
)
parser.add_argument(
"--task-suite",
help="Path to task_suite.yaml for evaluator (enables real LLM evaluation)",
)
parser.add_argument(
"--eval-mock",
action="store_true",
help="Run evaluator in mock mode (no claude -p calls)",
)
return parser.parse_args(argv)
# ---------------------------------------------------------------------------
# Subprocess helpers
# ---------------------------------------------------------------------------
def _run_script(cmd: list[str], label: str) -> str:
"""Run a subprocess and return its stdout (stripped).
Raises RuntimeError on non-zero exit.
"""
result = subprocess.run(cmd, capture_output=True, text=True, timeout=1200)
if result.returncode != 0:
raise RuntimeError(
f"{label} failed (exit {result.returncode}):\n"
f" stdout: {result.stdout.strip()}\n"
f" stderr: {result.stderr.strip()}"
)
return result.stdout.strip()
def run_script(script: Path, args: list[str], label: str) -> Path:
"""Run a script with args and return the artifact path as a Path.
Higher-level wrapper around subprocess that:
- Raises RuntimeError (with stderr) on non-zero exit.
- Raises RuntimeError when stdout is blank.
- Returns the last non-blank line of stdout as a resolved Path.
"""
cmd = [sys.executable, str(script)] + args
result = subprocess.run(cmd, capture_output=True, text=True, timeout=1200)
if result.returncode != 0:
raise RuntimeError(
f"{label} failed (exit {result.returncode}):\n"
f" stdout: {result.stdout.strip()}\n"
f" stderr: {result.stderr.strip()}"
)
stdout = result.stdout.strip()
if not stdout:
raise RuntimeError(f"{label} produced no output")
path = Path(stdout)
if not path.is_absolute():
path = Path.cwd() / path
return path.resolve()
def run_proposer(
target: str,
sources: list[str],
state_root: str,
trace: str | None = None,
) -> dict[str, Any]:
"""Call propose.py and return the candidate artifact."""
cmd = [
sys.executable,
str(GENERATOR_SCRIPT),
"--target",
str(target),
"--state-root",
str(state_root),
]
for s in sources:
cmd.extend(["--source", str(s)])
if trace:
cmd.extend(["--trace", str(trace)])
artifact_path = _run_script(cmd, "proposer")
return read_json(Path(artifact_path))
def run_discriminator(
candidate_artifact_path: str,
state_root: str,
) -> dict[str, Any]:
"""Call score.py and return the ranking artifact."""
cmd = [
sys.executable,
str(DISCRIMINATOR_SCRIPT),
"--input",
str(candidate_artifact_path),
"--state-root",
str(state_root),
]
artifact_path = _run_script(cmd, "discriminator")
return read_json(Path(artifact_path))
def run_executor(
ranking_artifact_path: str,
candidate_id: str,
state_root: str,
) -> dict[str, Any]:
"""Call execute.py and return the execution artifact."""
cmd = [
sys.executable,
str(EXECUTOR_SCRIPT),
"--input",
str(ranking_artifact_path),
"--candidate-id",
candidate_id,
"--state-root",
str(state_root),
]
artifact_path = _run_script(cmd, "executor")
return read_json(Path(artifact_path))
def run_evaluator(
ranking_artifact_path: str,
candidate_id: str,
state_root: str,
task_suite: str | None = None,
eval_threshold: float = 6.0,
mock: bool = False,
) -> dict[str, Any] | None:
"""Call evaluate.py if a task suite exists for the target skill.
Returns evaluation artifact dict, or None if skipped (no task suite
or evaluator script not found).
"""
if not EVALUATOR_SCRIPT.exists():
print(" Evaluator: skipped (script not found)")
return None
if not task_suite:
print(" Evaluator: skipped (no --task-suite provided)")
return None
cmd = [
sys.executable,
str(EVALUATOR_SCRIPT),
"--input", str(ranking_artifact_path),
"--candidate-id", candidate_id,
"--task-suite", str(task_suite),
"--state-root", str(state_root),
"--eval-threshold", str(eval_threshold),
]
if mock:
cmd.append("--mock")
try:
artifact_path = _run_script(cmd, "evaluator")
result = read_json(Path(artifact_path))
evaluation = result.get("evaluation", {})
verdict = evaluation.get("verdict", result.get("verdict", "skipped"))
if verdict == "skipped":
print(" Evaluator: skipped (no task suite)")
return None
print(f" Evaluator: {verdict} (pass_rate={evaluation.get('execution_pass_rate', 'N/A')})")
return result
except RuntimeError as exc:
print(f" Evaluator: error ({exc}), continuing without evaluation")
return None
def run_gate(
ranking_artifact_path: str,
execution_artifact_path: str,
state_root: str,
) -> dict[str, Any]:
"""Call gate.py and return the gate receipt."""
cmd = [
sys.executable,
str(GATE_SCRIPT),
"--ranking",
str(ranking_artifact_path),
"--execution",
str(execution_artifact_path),
"--state-root",
str(state_root),
]
artifact_path = _run_script(cmd, "gate")
return read_json(Path(artifact_path))
# ---------------------------------------------------------------------------
# Candidate selection
# ---------------------------------------------------------------------------
def find_best_accepted(ranking_artifact: dict[str, Any]) -> dict[str, Any] | None:
"""Return the highest-scored candidate with recommendation=accept_for_execution.
Candidates in the ranking artifact are already sorted by score (descending),
so the first match is the best.
"""
for candidate in ranking_artifact.get("scored_candidates", []):
if candidate.get("recommendation") == "accept_for_execution":
return candidate
return None
# ---------------------------------------------------------------------------
# Failure trace (Ralph Wiggum feedback loop)
# ---------------------------------------------------------------------------
def extract_failure_trace(
receipt: dict[str, Any],
execution_artifact: dict[str, Any],
state_root: str,
) -> str:
"""Extract structured failure trace and write it to a file.
The returned path can be passed as --trace to the next proposer round,
enabling the Ralph Wiggum retry pattern: failures feed forward as
context for the next attempt.
"""
trace = {
"type": "failure_trace",
"candidate_id": receipt.get("candidate_id"),
"decision": receipt.get("decision"),
"reason": receipt.get("reason"),
"execution_status": execution_artifact.get("result", {}).get("status"),
"diff": execution_artifact.get("result", {}).get("diff", ""),
"gate_blockers": receipt.get("blockers", []),
"timestamp": utc_now_iso(),
}
run_id = receipt.get("run_id", "unknown")
trace_dir = Path(state_root) / "traces"
trace_path = trace_dir / f"trace-{run_id}.json"
write_json(trace_path, trace)
return str(trace_path)
# ---------------------------------------------------------------------------
# Pipeline
# ---------------------------------------------------------------------------
def run_baseline_evaluation(
target: str,
task_suite: str,
state_root: str,
mock: bool = False,
) -> str | None:
"""Run evaluator in standalone mode on current SKILL.md to get baseline failures.
Returns path to a feedback source file containing per-task failure details,
or None if no failures found or evaluator unavailable.
"""
if not EVALUATOR_SCRIPT.exists():
return None
cmd = [
sys.executable,
str(EVALUATOR_SCRIPT),
"--standalone",
"--task-suite", str(task_suite),
"--state-root", str(state_root),
"--skill-path", str(target),
]
if mock:
cmd.append("--mock")
try:
stdout = _run_script(cmd, "baseline-evaluator")
result = read_json(Path(stdout))
except RuntimeError as exc:
print(f" Baseline evaluation failed ({exc}), continuing without feedback")
return None
# Extract failed tasks as feedback for generator
task_results = result.get("task_results", [])
failed_tasks = [r for r in task_results if not r.get("passed")]
if not failed_tasks:
print(f" Baseline: all tasks passed, no failures to feed back")
return None
pass_rate = result.get("evaluation", {}).get("pass_rate", 0)
print(f" Baseline: {len(failed_tasks)} task(s) failed (pass_rate={pass_rate})")
# Write failure details as a source file for generator
feedback = {
"type": "evaluator_baseline_failures",
"pass_rate": pass_rate,
"failed_tasks": [],
"timestamp": utc_now_iso(),
}
for t in failed_tasks:
feedback["failed_tasks"].append({
"task_id": t.get("task_id"),
"score": t.get("score", 0),
"details": t.get("details", ""),
"error": t.get("error", ""),
})
feedback_dir = Path(state_root) / "traces"
feedback_dir.mkdir(parents=True, exist_ok=True)
feedback_path = feedback_dir / "baseline-failures.json"
write_json(feedback_path, feedback)
return str(feedback_path)
def run_pipeline(
target: str,
sources: list[str],
state_root: str,
max_retries: int = 3,
task_suite: str | None = None,
eval_mock: bool = False,
) -> dict[str, Any]:
"""Run the full PROPOSE → DISCRIMINATE → EVALUATE → EXECUTE → GATE loop.
Returns a summary dict with the final outcome.
"""
# Mutable copy so we can append failure traces across retries
active_sources = list(sources)
active_trace: str | None = None
final_decision = "no_candidates"
final_candidate_id: str | None = None
final_artifact_path: str | None = None
attempts_used = 0
# 0. BASELINE EVALUATION — run evaluator on current SKILL.md first
# to discover which tasks fail, then inject as feedback for generator
if task_suite:
baseline_feedback = run_baseline_evaluation(
target, task_suite, state_root, mock=eval_mock,
)
if baseline_feedback:
active_sources.append(baseline_feedback)
for attempt in range(1, max_retries + 1):
attempts_used = attempt
# 1. PROPOSE
candidate_artifact = run_proposer(target, active_sources, state_root, trace=active_trace)
candidate_artifact_path = candidate_artifact.get("truth_anchor", "")
# 2. DISCRIMINATE (score + rank)
ranking_artifact = run_discriminator(candidate_artifact_path, state_root)
ranking_artifact_path = ranking_artifact.get("truth_anchor", "")
# 3. Find best accepted candidate
best = find_best_accepted(ranking_artifact)
if not best:
if attempt >= max_retries:
final_decision = "no_accepted_candidates"
print(f" No accepted candidates after {attempt} attempts")
break
# Inject rejection trace for next round
trace_dir = Path(state_root) / "traces"
trace_dir.mkdir(parents=True, exist_ok=True)
rejection_trace = {
"type": "all_rejected_trace",
"scored_candidates": [
{"id": c.get("id"), "score": c.get("score"), "recommendation": c.get("recommendation")}
for c in ranking_artifact.get("scored_candidates", [])
],
"timestamp": utc_now_iso(),
}
trace_path = trace_dir / f"rejection-trace-attempt-{attempt}.json"
write_json(trace_path, rejection_trace)
active_trace = str(trace_path)
print(f" No accepted candidates, retrying ({attempt}/{max_retries})")
continue
candidate_id = best["id"]
# 3.5 EVALUATE (optional — skipped if no --task-suite provided)
eval_result = run_evaluator(
ranking_artifact_path,
candidate_id,
state_root,
task_suite=task_suite,
mock=eval_mock,
)
eval_verdict = eval_result.get("evaluation", {}).get("verdict") if eval_result else None
if eval_verdict == "fail":
# Evaluation failed — treat as revert and retry
trace = {
"type": "evaluation_failure_trace",
"candidate_id": candidate_id,
"evaluation_results": eval_result.get("evaluation", {}),
"timestamp": utc_now_iso(),
}
trace_dir = Path(state_root) / "traces"
trace_dir.mkdir(parents=True, exist_ok=True)
trace_path = trace_dir / f"eval-trace-{candidate_id}.json"
write_json(trace_path, trace)
active_trace = str(trace_path)
print(f" Evaluation failed, retrying ({attempt}/{max_retries})")
continue
# 4. EXECUTE
execution_artifact = run_executor(
ranking_artifact_path,
candidate_id,
state_root,
)
execution_artifact_path = execution_artifact.get("truth_anchor", "")
# 5. GATE (verify after execution)
receipt = run_gate(
ranking_artifact_path,
execution_artifact_path,
state_root,
)
# 6. DECIDE
decision = receipt.get("decision", "reject")
final_decision = decision
final_candidate_id = candidate_id
final_artifact_path = receipt.get("truth_anchor")
if decision == "keep":
print(f" Kept: {candidate_id}")
break
elif decision == "revert":
# Ralph Wiggum: capture trace, inject into next round
trace_path = extract_failure_trace(
receipt, execution_artifact, state_root,
)
active_trace = trace_path
print(
f" Reverted, retrying ({attempt}/{max_retries})"
)
continue
elif decision == "pending_promote":
print(f" Pending human review: {candidate_id}")
break
else: # reject
print(f" Rejected: {candidate_id}")
break
return {
"target": target,
"attempts": attempts_used,
"max_retries": max_retries,
"final_decision": final_decision,
"final_candidate_id": final_candidate_id,
"final_artifact_path": final_artifact_path,
}
# ---------------------------------------------------------------------------
# Summary report
# ---------------------------------------------------------------------------
def print_summary(summary: dict[str, Any]) -> None:
"""Print a human-readable pipeline summary."""
print("\nPipeline Summary:")
print(f" Target: {summary['target']}")
print(f" Attempts: {summary['attempts']}/{summary['max_retries']}")
print(f" Final Decision: {summary['final_decision']}")
print(f" Candidate: {summary.get('final_candidate_id', 'N/A')}")
print(f" Artifact: {summary.get('final_artifact_path', 'N/A')}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
target = str(Path(args.target).expanduser().resolve())
state_root = str(Path(args.state_root).expanduser().resolve())
sources = [str(Path(s).expanduser().resolve()) for s in args.source if s]
task_suite = str(Path(args.task_suite).expanduser().resolve()) if args.task_suite else None
try:
summary = run_pipeline(
target=target,
sources=sources,
state_root=state_root,
max_retries=args.max_retries,
task_suite=task_suite,
eval_mock=args.eval_mock,
)
except RuntimeError as exc:
print(f"Pipeline error: {exc}", file=sys.stderr)
return 1
print_summary(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Tests for the orchestrator dispatch pipeline."""
from __future__ import annotations
import json
import sys
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
REPO_ROOT = _REPO_ROOT
# ---------------------------------------------------------------------------
# Import the module directly via importlib (scripts dir is not a package).
# ---------------------------------------------------------------------------
import importlib.util
_spec = importlib.util.spec_from_file_location(
"orchestrate",
REPO_ROOT / "skills" / "improvement-orchestrator" / "scripts" / "orchestrate.py",
)
orchestrate = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(orchestrate)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_state(tmp_path):
"""Create a minimal state tree for testing."""
for sub in ("candidate_versions", "rankings", "executions", "state", "receipts", "traces"):
(tmp_path / sub).mkdir()
# Seed state files so ensure_tree doesn't fail
(tmp_path / "state" / "current_state.json").write_text("{}")
(tmp_path / "state" / "pending_promote.json").write_text('{"pending":[], "last_updated":null}')
(tmp_path / "state" / "veto.json").write_text('{"vetoes":[], "last_updated":null}')
(tmp_path / "state" / "last_run.json").write_text("{}")
return tmp_path
def _make_ranking_artifact(candidates: list[dict]) -> dict:
"""Build a minimal ranking artifact with scored_candidates."""
return {
"run_id": "test-run-001",
"target": {"path": "/fake/target"},
"scored_candidates": candidates,
"truth_anchor": "/fake/ranking.json",
}
def _make_execution_artifact(
candidate_id: str = "cand-01-docs",
status: str = "success",
modified: bool = True,
diff: str = "--- a\n+++ b\n@@ -1 +1,2 @@\n+new line",
) -> dict:
return {
"run_id": "test-run-001",
"candidate_id": candidate_id,
"result": {
"status": status,
"modified": modified,
"diff": diff,
},
"truth_anchor": "/fake/execution.json",
}
def _make_receipt(
candidate_id: str = "cand-01-docs",
decision: str = "keep",
reason: str = "low-risk docs candidate",
run_id: str = "test-run-001",
blockers: list | None = None,
) -> dict:
return {
"run_id": run_id,
"candidate_id": candidate_id,
"decision": decision,
"reason": reason,
"blockers": blockers or [],
"truth_anchor": "/fake/receipt.json",
}
# ---------------------------------------------------------------------------
# Tests: find_best_accepted
# ---------------------------------------------------------------------------
class TestFindBestAccepted:
def test_returns_first_accepted(self):
ranking = _make_ranking_artifact([
{"id": "cand-01", "score": 8.0, "recommendation": "accept_for_execution"},
{"id": "cand-02", "score": 7.0, "recommendation": "accept_for_execution"},
{"id": "cand-03", "score": 6.0, "recommendation": "hold"},
])
best = orchestrate.find_best_accepted(ranking)
assert best is not None
assert best["id"] == "cand-01"
def test_returns_none_when_no_accepted(self):
ranking = _make_ranking_artifact([
{"id": "cand-01", "score": 5.0, "recommendation": "hold"},
{"id": "cand-02", "score": 3.0, "recommendation": "reject"},
])
best = orchestrate.find_best_accepted(ranking)
assert best is None
def test_returns_none_for_empty_candidates(self):
ranking = _make_ranking_artifact([])
best = orchestrate.find_best_accepted(ranking)
assert best is None
def test_skips_non_accepted(self):
ranking = _make_ranking_artifact([
{"id": "cand-01", "score": 9.0, "recommendation": "hold"},
{"id": "cand-02", "score": 7.5, "recommendation": "accept_for_execution"},
])
best = orchestrate.find_best_accepted(ranking)
assert best["id"] == "cand-02"
# ---------------------------------------------------------------------------
# Tests: extract_failure_trace
# ---------------------------------------------------------------------------
class TestExtractFailureTrace:
def test_writes_trace_file(self, tmp_state):
receipt = _make_receipt(
decision="revert",
reason="execution failed validation",
blockers=["risk_medium"],
)
execution = _make_execution_artifact(status="error", diff="some diff")
trace_path = orchestrate.extract_failure_trace(
receipt, execution, str(tmp_state),
)
path = Path(trace_path)
assert path.exists()
assert path.suffix == ".json"
trace = json.loads(path.read_text())
assert trace["type"] == "failure_trace"
assert trace["candidate_id"] == "cand-01-docs"
assert trace["decision"] == "revert"
assert trace["reason"] == "execution failed validation"
assert trace["execution_status"] == "error"
assert trace["diff"] == "some diff"
assert trace["gate_blockers"] == ["risk_medium"]
assert "timestamp" in trace
def test_trace_path_uses_run_id(self, tmp_state):
receipt = _make_receipt(run_id="my-special-run-42", decision="revert")
execution = _make_execution_artifact()
trace_path = orchestrate.extract_failure_trace(
receipt, execution, str(tmp_state),
)
assert "my-special-run-42" in trace_path
def test_handles_missing_fields_gracefully(self, tmp_state):
receipt = {"run_id": "run-x"}
execution = {}
trace_path = orchestrate.extract_failure_trace(
receipt, execution, str(tmp_state),
)
trace = json.loads(Path(trace_path).read_text())
assert trace["type"] == "failure_trace"
assert trace["candidate_id"] is None
assert trace["decision"] is None
assert trace["execution_status"] is None
# ---------------------------------------------------------------------------
# Tests: run_pipeline (with mocked subprocesses)
# ---------------------------------------------------------------------------
def _mock_subprocess_side_effects(
tmp_state: Path,
decisions: list[str],
*,
has_accepted: bool = True,
):
"""Build a subprocess.run side-effect function that simulates
the full pipeline across multiple attempts.
Each call to subprocess.run returns a CompletedProcess with stdout
pointing to a temporary artifact file.
``decisions`` controls what the gate returns on each attempt.
"""
call_counter = {"n": 0, "attempt": 0}
def side_effect(cmd, **kwargs):
script_name = cmd[1] if len(cmd) > 1 else ""
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stderr = ""
if "propose.py" in script_name:
attempt = call_counter["attempt"]
artifact_path = tmp_state / "candidate_versions" / f"run-attempt-{attempt}.json"
artifact = {
"run_id": f"run-attempt-{attempt}",
"stage": "proposed",
"status": "success",
"target": {"path": "/fake/target"},
"candidates": [
{
"id": "cand-01-docs",
"category": "docs",
"risk_level": "low",
"executor_support": True,
"target_path": "/fake/target/README.md",
},
],
"truth_anchor": str(artifact_path),
}
artifact_path.write_text(json.dumps(artifact))
mock_result.stdout = str(artifact_path)
elif "score.py" in script_name:
attempt = call_counter["attempt"]
artifact_path = tmp_state / "rankings" / f"run-attempt-{attempt}.json"
rec = "accept_for_execution" if has_accepted else "hold"
artifact = {
"run_id": f"run-attempt-{attempt}",
"stage": "ranked",
"status": "success",
"target": {"path": "/fake/target"},
"scored_candidates": [
{
"id": "cand-01-docs",
"score": 8.5,
"recommendation": rec,
"category": "docs",
"risk_level": "low",
"target_path": "/fake/target/README.md",
},
],
"truth_anchor": str(artifact_path),
}
artifact_path.write_text(json.dumps(artifact))
mock_result.stdout = str(artifact_path)
elif "execute.py" in script_name:
attempt = call_counter["attempt"]
artifact_path = tmp_state / "executions" / f"run-attempt-{attempt}.json"
artifact = {
"run_id": f"run-attempt-{attempt}",
"candidate_id": "cand-01-docs",
"stage": "executed",
"status": "success",
"result": {
"status": "success",
"modified": True,
"diff": "+new line",
},
"truth_anchor": str(artifact_path),
}
artifact_path.write_text(json.dumps(artifact))
mock_result.stdout = str(artifact_path)
elif "gate.py" in script_name:
attempt = call_counter["attempt"]
decision = decisions[attempt] if attempt < len(decisions) else "reject"
artifact_path = tmp_state / "receipts" / f"gate-run-attempt-{attempt}.json"
receipt = {
"run_id": f"run-attempt-{attempt}",
"candidate_id": "cand-01-docs",
"decision": decision,
"reason": f"test decision: {decision}",
"blockers": [],
"truth_anchor": str(artifact_path),
}
artifact_path.write_text(json.dumps(receipt))
mock_result.stdout = str(artifact_path)
call_counter["attempt"] += 1
return mock_result
return side_effect
class TestRunPipeline:
# Patch EVALUATOR_SCRIPT to a non-existent path so run_evaluator() skips
# (the evaluator is optional and returns None when script doesn't exist).
_no_evaluator = patch.object(
orchestrate, "EVALUATOR_SCRIPT", Path("/nonexistent/evaluate.py"),
)
def test_keep_on_first_attempt(self, tmp_state):
side_effect = _mock_subprocess_side_effects(tmp_state, ["keep"])
with self._no_evaluator, patch("subprocess.run", side_effect=side_effect):
summary = orchestrate.run_pipeline(
target="/fake/target",
sources=[],
state_root=str(tmp_state),
max_retries=3,
)
assert summary["final_decision"] == "keep"
assert summary["attempts"] == 1
assert summary["final_candidate_id"] == "cand-01-docs"
def test_revert_then_keep(self, tmp_state):
side_effect = _mock_subprocess_side_effects(
tmp_state, ["revert", "keep"],
)
with self._no_evaluator, patch("subprocess.run", side_effect=side_effect):
summary = orchestrate.run_pipeline(
target="/fake/target",
sources=[],
state_root=str(tmp_state),
max_retries=3,
)
assert summary["final_decision"] == "keep"
assert summary["attempts"] == 2
# Verify trace file was created for the revert
traces = list((tmp_state / "traces").glob("trace-*.json"))
assert len(traces) == 1
def test_pending_promote_stops_loop(self, tmp_state):
side_effect = _mock_subprocess_side_effects(
tmp_state, ["pending_promote"],
)
with self._no_evaluator, patch("subprocess.run", side_effect=side_effect):
summary = orchestrate.run_pipeline(
target="/fake/target",
sources=[],
state_root=str(tmp_state),
max_retries=3,
)
assert summary["final_decision"] == "pending_promote"
assert summary["attempts"] == 1
def test_reject_stops_loop(self, tmp_state):
side_effect = _mock_subprocess_side_effects(
tmp_state, ["reject"],
)
with self._no_evaluator, patch("subprocess.run", side_effect=side_effect):
summary = orchestrate.run_pipeline(
target="/fake/target",
sources=[],
state_root=str(tmp_state),
max_retries=3,
)
assert summary["final_decision"] == "reject"
assert summary["attempts"] == 1
def test_max_retries_exhausted(self, tmp_state):
side_effect = _mock_subprocess_side_effects(
tmp_state, ["revert", "revert", "revert"],
)
with self._no_evaluator, patch("subprocess.run", side_effect=side_effect):
summary = orchestrate.run_pipeline(
target="/fake/target",
sources=[],
state_root=str(tmp_state),
max_retries=3,
)
# After 3 reverts the loop ends; last decision is "revert"
assert summary["final_decision"] == "revert"
assert summary["attempts"] == 3
# All 3 traces should exist
traces = list((tmp_state / "traces").glob("trace-*.json"))
assert len(traces) == 3
def test_no_accepted_candidates_exits_early(self, tmp_state):
side_effect = _mock_subprocess_side_effects(
tmp_state, ["keep"], has_accepted=False,
)
with patch("subprocess.run", side_effect=side_effect):
summary = orchestrate.run_pipeline(
target="/fake/target",
sources=[],
state_root=str(tmp_state),
max_retries=3,
)
assert summary["final_decision"] == "no_accepted_candidates"
# Pipeline retries up to max_retries before giving up
assert summary["attempts"] == 3
def test_failure_trace_fed_back_as_source(self, tmp_state):
"""Verify that after a revert the trace path is passed via --trace."""
call_log = {"proposer_calls": []}
original_side_effect = _mock_subprocess_side_effects(
tmp_state, ["revert", "keep"],
)
def tracking_side_effect(cmd, **kwargs):
script_name = cmd[1] if len(cmd) > 1 else ""
if "propose.py" in script_name:
# Capture --source and --trace args
sources_in_cmd = []
trace_in_cmd = None
for i, arg in enumerate(cmd):
if arg == "--source" and i + 1 < len(cmd):
sources_in_cmd.append(cmd[i + 1])
if arg == "--trace" and i + 1 < len(cmd):
trace_in_cmd = cmd[i + 1]
call_log["proposer_calls"].append({
"sources": sources_in_cmd,
"trace": trace_in_cmd,
})
return original_side_effect(cmd, **kwargs)
with self._no_evaluator, patch("subprocess.run", side_effect=tracking_side_effect):
orchestrate.run_pipeline(
target="/fake/target",
sources=["/original/source.md"],
state_root=str(tmp_state),
max_retries=3,
)
# First call: only original source, no trace
assert call_log["proposer_calls"][0]["sources"] == ["/original/source.md"]
assert call_log["proposer_calls"][0]["trace"] is None
# Second call: same source, plus a --trace pointing to trace file
second_call = call_log["proposer_calls"][1]
assert second_call["sources"] == ["/original/source.md"]
assert second_call["trace"] is not None
assert "trace-" in second_call["trace"]
# ---------------------------------------------------------------------------
# Tests: _run_script error handling
# ---------------------------------------------------------------------------
class TestRunScript:
def test_raises_on_nonzero_exit(self):
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = "some output"
mock_result.stderr = "some error"
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(RuntimeError, match="proposer failed"):
orchestrate._run_script(["python3", "fake.py"], "proposer")
class TestRunScriptNew:
"""Tests for the new run_script function."""
def test_raises_on_nonzero_exit(self):
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "traceback: something broke"
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(RuntimeError, match="executor failed"):
orchestrate.run_script(Path("fake.py"), [], "executor")
def test_raises_on_empty_stdout(self):
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = " \n \n "
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(RuntimeError, match="produced no output"):
orchestrate.run_script(Path("fake.py"), [], "scorer")
def test_returns_absolute_path_when_exists(self, tmp_path):
artifact = tmp_path / "artifact.json"
artifact.write_text("{}")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = f" \n{artifact}\n"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
result = orchestrate.run_script(Path("fake.py"), ["--flag"], "proposer")
assert result == artifact
def test_resolves_relative_path_via_cwd(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
artifact = tmp_path / "out" / "result.json"
artifact.parent.mkdir()
artifact.write_text("{}")
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "out/result.json\n"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
result = orchestrate.run_script(Path("fake.py"), [], "gate")
assert result.exists()
assert result.name == "result.json"
def test_stderr_included_in_error_message(self):
mock_result = MagicMock()
mock_result.returncode = 2
mock_result.stdout = ""
mock_result.stderr = "ImportError: no module named foo"
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(RuntimeError) as exc_info:
orchestrate.run_script(Path("fake.py"), [], "proposer")
assert "ImportError: no module named foo" in str(exc_info.value)
def test_returns_path_even_when_nonexistent(self):
mock_result = MagicMock()
mock_result.returncode = 0
mock_result.stdout = "/nonexistent/artifact.json\n"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
result = orchestrate.run_script(Path("fake.py"), [], "executor")
assert result == Path("/nonexistent/artifact.json")
# ---------------------------------------------------------------------------
# Tests: CLI parse_args
# ---------------------------------------------------------------------------
class TestParseArgs:
def test_required_args(self):
args = orchestrate.parse_args([
"--target", "/some/skill",
"--state-root", "/some/state",
])
assert args.target == "/some/skill"
assert args.state_root == "/some/state"
assert args.max_retries == 3
assert args.auto is False
assert args.source == []
def test_all_args(self):
args = orchestrate.parse_args([
"--target", "/some/skill",
"--source", "/a.md",
"--source", "/b.md",
"--state-root", "/some/state",
"--max-retries", "5",
"--auto",
])
assert args.source == ["/a.md", "/b.md"]
assert args.max_retries == 5
assert args.auto is True
"""Tests for the orchestrator state machine and common helpers."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
# Add repo root to path so we can import lib.*
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.common import (
slugify,
utc_now_iso,
compact_timestamp,
protected_target,
classify_feedback,
infer_source_kind,
compute_target_profile,
read_json,
write_json,
KEEP_CATEGORIES,
EXECUTOR_SUPPORTED_CATEGORIES,
)
from lib.state_machine import (
ensure_tree,
next_step_for_stage,
update_state,
append_pending_promote,
append_veto,
)
# --- slugify ---
class TestSlugify:
def test_simple(self):
assert slugify("Hello World") == "hello-world"
def test_special_chars(self):
assert slugify("my_skill/v2.0") == "my-skill-v2-0"
def test_empty(self):
assert slugify("") == "item"
def test_trailing_special(self):
assert slugify("---test---") == "test"
# --- timestamp helpers ---
class TestTimestamps:
def test_utc_now_iso_format(self):
result = utc_now_iso()
assert result.endswith("Z")
assert "T" in result
def test_compact_timestamp_format(self):
result = compact_timestamp()
assert result.endswith("Z")
assert "T" in result
assert len(result) == 16 # YYYYMMDDTHHMMSSz
# --- state machine transitions ---
class TestStateMachine:
def test_proposed_goes_to_critic(self):
step, owner = next_step_for_stage("proposed")
assert step == "rank_candidates"
assert owner == "critic"
def test_ranked_goes_to_evaluator(self):
step, owner = next_step_for_stage("ranked")
assert step == "evaluate_candidate"
assert owner == "evaluator"
def test_evaluated_goes_to_executor(self):
step, owner = next_step_for_stage("evaluated")
assert step == "execute_candidate"
assert owner == "executor"
def test_executed_goes_to_gate(self):
step, owner = next_step_for_stage("executed")
assert step == "apply_gate"
assert owner == "gate"
def test_gated_keep_cycles_back(self):
step, owner = next_step_for_stage("gated_keep")
assert step == "propose_candidates"
assert owner == "proposer"
def test_gated_pending_needs_human(self):
step, owner = next_step_for_stage("gated_pending")
assert owner == "human"
def test_gated_revert_goes_to_proposer(self):
step, owner = next_step_for_stage("gated_revert")
assert owner == "proposer"
def test_gated_reject_goes_to_proposer(self):
step, owner = next_step_for_stage("gated_reject")
assert owner == "proposer"
def test_unknown_stage_falls_back(self):
step, owner = next_step_for_stage("nonexistent")
assert step == "inspect_state"
assert owner == "human"
# --- ensure_tree ---
class TestEnsureTree:
def test_creates_directory_structure(self, tmp_path):
root = tmp_path / "test_state"
paths = ensure_tree(root)
assert paths["root"].exists()
assert paths["candidate_versions"].is_dir()
assert paths["rankings"].is_dir()
assert paths["executions"].is_dir()
assert paths["state"].is_dir()
assert paths["receipts"].is_dir()
def test_creates_state_files(self, tmp_path):
root = tmp_path / "test_state"
paths = ensure_tree(root)
state_dir = paths["state"]
assert (state_dir / "current_state.json").exists()
assert (state_dir / "pending_promote.json").exists()
assert (state_dir / "veto.json").exists()
assert (state_dir / "last_run.json").exists()
def test_initial_state_is_idle(self, tmp_path):
root = tmp_path / "test_state"
paths = ensure_tree(root)
state = read_json(paths["state"] / "current_state.json")
assert state["status"] == "idle"
assert state["stage"] == "idle"
assert state["next_owner"] == "proposer"
def test_idempotent(self, tmp_path):
root = tmp_path / "test_state"
ensure_tree(root)
ensure_tree(root) # should not raise
# --- update_state ---
class TestUpdateState:
def test_updates_current_and_last_run(self, tmp_path):
root = tmp_path / "state"
ensure_tree(root)
update_state(
root,
run_id="run-001",
stage="proposed",
status="completed",
target_path="/some/skill",
truth_anchor="/some/artifact.json",
)
current = read_json(root / "state" / "current_state.json")
assert current["current_run_id"] == "run-001"
assert current["stage"] == "proposed"
assert current["next_owner"] == "critic"
last_run = read_json(root / "state" / "last_run.json")
assert last_run["last_run_id"] == "run-001"
assert last_run["last_stage"] == "proposed"
def test_full_cycle(self, tmp_path):
"""Simulate a full Proposer→Critic→Executor→Gate cycle."""
root = tmp_path / "state"
ensure_tree(root)
stages = [
("proposed", "completed"),
("ranked", "completed"),
("evaluated", "completed"),
("executed", "completed"),
("gated_keep", "completed"),
]
expected_owners = ["critic", "evaluator", "executor", "gate", "proposer"]
for (stage, status), expected_owner in zip(stages, expected_owners):
update_state(
root,
run_id="run-cycle",
stage=stage,
status=status,
target_path="/skill",
truth_anchor=f"/artifact-{stage}.json",
)
current = read_json(root / "state" / "current_state.json")
assert current["next_owner"] == expected_owner, f"After {stage}"
# --- pending_promote / veto ---
class TestPendingAndVeto:
def test_append_pending_promote(self, tmp_path):
root = tmp_path / "state"
ensure_tree(root)
append_pending_promote(root, {"id": "c1", "reason": "hold"})
append_pending_promote(root, {"id": "c2", "reason": "hold"})
data = read_json(root / "state" / "pending_promote.json")
assert len(data["pending"]) == 2
assert data["last_updated"] is not None
def test_append_veto(self, tmp_path):
root = tmp_path / "state"
ensure_tree(root)
append_veto(root, {"id": "c3", "reason": "high risk"})
data = read_json(root / "state" / "veto.json")
assert len(data["vetoes"]) == 1
# --- protected_target ---
class TestProtectedTarget:
def test_trading_is_protected(self):
assert protected_target("/skills/trading-bot") is True
def test_gateway_is_protected(self):
assert protected_target("/infra/gateway-config") is True
def test_normal_skill_not_protected(self):
assert protected_target("/skills/summarize") is False
# --- classify_feedback ---
class TestClassifyFeedback:
def test_basic_classification(self):
entries = [
{"snippet": "This test case fails on edge cases"},
{"snippet": "The workflow step is unclear"},
{"snippet": "Add a usage example for beginners"},
]
result = classify_feedback(entries)
assert len(result["tests"]) >= 1
assert len(result["workflow"]) >= 1
assert len(result["examples"]) >= 1
# --- infer_source_kind ---
class TestInferSourceKind:
def test_feedback(self):
assert infer_source_kind(Path(".feedback/note.md")) == "feedback"
def test_learnings(self):
assert infer_source_kind(Path("learnings/2024.md")) == "learnings"
def test_memory(self):
assert infer_source_kind(Path("memory/state.json")) == "memory"
def test_generic(self):
assert infer_source_kind(Path("README.md")) == "source"
# --- compute_target_profile ---
class TestComputeTargetProfile:
def test_directory_profile(self, tmp_path):
(tmp_path / "README.md").write_text("# Test")
(tmp_path / "references").mkdir()
profile = compute_target_profile(tmp_path)
assert profile["exists"] is True
assert profile["kind"] == "directory"
assert profile["has_references"] is True
assert len(profile["markdown_files"]) >= 1
def test_missing_path(self, tmp_path):
profile = compute_target_profile(tmp_path / "nonexistent")
assert profile["exists"] is False
assert profile["kind"] == "missing"
# --- constants consistency ---
class TestConstants:
def test_keep_is_subset_of_executor_supported(self):
assert KEEP_CATEGORIES.issubset(EXECUTOR_SUPPORTED_CATEGORIES)