Skip to content

Commit 7f32ea4

Browse files
committed
fix: tighten synthesized skill quality gates
1 parent a758a96 commit 7f32ea4

11 files changed

Lines changed: 425 additions & 34 deletions

File tree

‎plugins/codex-self-evolution/.codex-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codex-self-evolution",
3-
"version": "0.7.7",
3+
"version": "0.7.8",
44
"description": "Scheduled self-evolution loop with stable background memory, provider-backed stop review, explicit suggestion states, launchd compiler preflight, focused recall, and independent csep-synth skill synthesis.",
55
"author": {
66
"name": "T0UGH",

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codex-self-evolution-plugin"
7-
version = "0.7.7"
7+
version = "0.7.8"
88
description = "Self-evolution loop for OpenAI Codex: Stop-hook reviewer, memory/recall compiler, and independent skill synthesis, zero runtime dependencies."
99
readme = "README.md"
1010
requires-python = ">=3.11"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__all__ = ["__version__"]
22

3-
__version__ = "0.7.7"
3+
__version__ = "0.7.8"

‎src/codex_self_evolution/plugin_bundle/.codex-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codex-self-evolution",
3-
"version": "0.7.7",
3+
"version": "0.7.8",
44
"description": "Scheduled self-evolution loop with stable background memory, provider-backed stop review, explicit suggestion states, launchd compiler preflight, focused recall, and independent csep-synth skill synthesis.",
55
"author": {
66
"name": "T0UGH",

‎src/codex_self_evolution/skill_synthesis/agent.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ class SkillSynthesisAgentError(RuntimeError):
1313

1414

1515
AgentInvoker = Callable[[list[str], float], subprocess.CompletedProcess[str]]
16+
_EVIDENCE_KINDS = {"workflow", "fact", "rule", "preference", "duplicate", "transient"}
1617

1718

1819
def build_skill_synthesis_prompt(*, run_input_dir: Path, run_output_dir: Path, skills_root: Path) -> str:
20+
"""Build the bounded SOP and result contract for the synthesis agent."""
1921
result_path = run_output_dir / "result.json"
2022
return (
2123
"You are the codex-self-evolution skill synthesis agent.\n\n"
@@ -29,6 +31,52 @@ def build_skill_synthesis_prompt(*, run_input_dir: Path, run_output_dir: Path, s
2931
"4. Create, edit, or retire skills only for repeated reusable workflows.\n"
3032
"5. Do not create skills for one-off facts, temporary state, secrets, or account identifiers.\n"
3133
"6. Retired skills must use description \"Retired generated skill. Do not use.\" and csep_status: retired.\n\n"
34+
"Classify every candidate before writing anything: fact|rule|preference|workflow|duplicate|transient.\n"
35+
"Only workflow candidates may become active skills. Facts belong in memory, behavior rules belong in AGENTS/memory,\n"
36+
"preferences belong in USER.md, transient state should be skipped, and duplicate workflows should edit or retire an existing csep-synth skill.\n\n"
37+
"Active SKILL.md frontmatter must be simple single-line YAML:\n"
38+
"---\n"
39+
"name: csep-synth-example\n"
40+
"description: Use when repeated example workflow needs concrete command guidance.\n"
41+
"---\n"
42+
"Do not use block scalar descriptions such as `description: |`.\n"
43+
"The frontmatter name must match the csep-synth-* directory name exactly.\n"
44+
"Only retired skills may contain the retired body line.\n\n"
45+
"Active SKILL.md bodies must use this contract:\n"
46+
"# <Skill title>\n\n"
47+
"## Skill Decision\n\n"
48+
"- Why this is a skill: <why the evidence is a reusable workflow>\n"
49+
"- Why not memory: <why this is not a static fact, preference, or rule>\n"
50+
"- Existing skill boundary: <nearest existing skill and why this is not duplicate>\n\n"
51+
"## When to Use\n\n"
52+
"<concrete trigger conditions>\n\n"
53+
"## Inputs\n\n"
54+
"<required ids, paths, links, env vars, or prerequisites>\n\n"
55+
"## Workflow\n\n"
56+
"<ordered tool/command steps>\n\n"
57+
"## Verification\n\n"
58+
"<evidence that proves success>\n\n"
59+
"## Failure Handling\n\n"
60+
"<when to stop, ask, skip, retire, or report uncertainty>\n\n"
61+
"Write result.json with exactly this schema:\n"
62+
"{\n"
63+
" \"schema_version\": 1,\n"
64+
" \"run_id\": \"<current run id if known>\",\n"
65+
" \"actions\": [\n"
66+
" {\n"
67+
" \"action\": \"create|edit|retire|skip\",\n"
68+
" \"skill_id\": \"csep-synth-example\",\n"
69+
" \"path\": \"<absolute path to SKILL.md, or empty for skip>\",\n"
70+
" \"evidence_keys\": [\"<evidence key>\"],\n"
71+
" \"reason\": \"why this action is justified\",\n"
72+
" \"evidence_kind\": \"workflow|fact|rule|preference|duplicate|transient\",\n"
73+
" \"why_skill_not_memory\": \"required for create/edit\",\n"
74+
" \"existing_skill_overlap\": \"nearest existing skill or no direct overlap\"\n"
75+
" }\n"
76+
" ],\n"
77+
" \"notes\": \"optional\"\n"
78+
"}\n"
79+
"Do not write legacy result shapes such as synthesized, valid_skills, invalid, retired, or skipped as top-level substitutes for actions.\n\n"
3280
"After writing files, return exactly DONE."
3381
)
3482

@@ -86,6 +134,7 @@ def _subprocess_run(cmd: list[str], timeout_seconds: float) -> subprocess.Comple
86134

87135

88136
def parse_agent_result(path: Path) -> dict[str, Any]:
137+
"""Parse and validate the synthesis agent's result.json contract."""
89138
if not path.is_file():
90139
return {"valid": False, "actions": [], "result_missing": True, "result_invalid": False}
91140
try:
@@ -107,12 +156,25 @@ def parse_agent_result(path: Path) -> dict[str, Any]:
107156
return {"valid": False, "actions": [], "result_missing": False, "result_invalid": True, "error": "bad_action"}
108157
if skill_id and not re.fullmatch(r"csep-synth-[a-z0-9-]+", skill_id):
109158
return {"valid": False, "actions": [], "result_missing": False, "result_invalid": True, "error": "bad_skill_id"}
159+
evidence_kind = str(item.get("evidence_kind") or "")
160+
why_skill_not_memory = str(item.get("why_skill_not_memory") or "").strip()
161+
existing_skill_overlap = str(item.get("existing_skill_overlap") or "").strip()
162+
if evidence_kind and evidence_kind not in _EVIDENCE_KINDS:
163+
return {"valid": False, "actions": [], "result_missing": False, "result_invalid": True, "error": "bad_evidence_kind"}
164+
if action in {"create", "edit"}:
165+
if not evidence_kind or not why_skill_not_memory or not existing_skill_overlap:
166+
return {"valid": False, "actions": [], "result_missing": False, "result_invalid": True, "error": "missing_skill_decision"}
167+
if evidence_kind != "workflow":
168+
return {"valid": False, "actions": [], "result_missing": False, "result_invalid": True, "error": "non_workflow_skill"}
110169
parsed.append({
111170
"action": action,
112171
"skill_id": skill_id,
113172
"path": str(item.get("path") or ""),
114173
"evidence_keys": [str(v) for v in item.get("evidence_keys", []) if isinstance(v, str)],
115174
"reason": str(item.get("reason") or ""),
175+
"evidence_kind": evidence_kind,
176+
"why_skill_not_memory": why_skill_not_memory,
177+
"existing_skill_overlap": existing_skill_overlap,
116178
})
117179
return {
118180
"valid": True,

‎src/codex_self_evolution/skill_synthesis/runner.py‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,16 @@ def _run_locked(
149149
namespace_violations = _non_synth_changed_paths(changed_paths(all_before, all_after))
150150
changed_real = changed_paths(real_before, real_after) if dry_run else []
151151
changed_real_all = changed_paths(real_all_before, real_all_after) if dry_run else []
152-
valid, invalid, retired = _validate_changed(run_id, detected)
152+
reported_written = _reported_written(agent_result)
153+
validation_targets = _validation_targets(
154+
skills_root=skills_root,
155+
changed=detected,
156+
reported=reported_written,
157+
)
158+
valid, invalid, retired = _validate_changed(run_id, validation_targets)
153159
result_missing = bool(agent_result.get("result_missing"))
154160
result_invalid = bool(agent_result.get("result_invalid"))
155-
mismatch = _reported_written(agent_result) != detected
161+
mismatch = reported_written != detected
156162
dry_run_leak = dry_run and bool(changed_real)
157163
status = "success"
158164
if error or dry_run_leak or namespace_violations:
@@ -170,7 +176,7 @@ def _run_locked(
170176
"evidence_count": len(evidence),
171177
"new_evidence_count": sum(1 for item in evidence if not item.get("seen_before")),
172178
"seen_before_count": sum(1 for item in evidence if item.get("seen_before")),
173-
"reported_written": _reported_written(agent_result),
179+
"reported_written": reported_written,
174180
"detected_changed": detected,
175181
"changed_real_paths": changed_real_all or changed_real,
176182
"namespace_violations": namespace_violations,
@@ -193,21 +199,47 @@ def _run_locked(
193199

194200

195201
def _reported_written(agent_result: dict[str, Any]) -> list[str]:
202+
"""Return sorted SKILL.md paths the agent claims to have written."""
196203
out: list[str] = []
197204
for action in agent_result.get("actions", []):
198205
if action.get("action") in {"create", "edit", "retire"} and action.get("path"):
199206
out.append(str(action["path"]))
200207
return sorted(out)
201208

202209

210+
def _validation_targets(*, skills_root: Path, changed: list[str], reported: list[str]) -> list[str]:
211+
"""Select generated skill files that should receive post-run validation."""
212+
root = skills_root.expanduser().resolve(strict=False)
213+
out = {
214+
str(Path(raw).expanduser())
215+
for raw in [*changed, *reported]
216+
if _under_root(Path(raw).expanduser(), root)
217+
}
218+
if skills_root.is_dir():
219+
for child in sorted(skills_root.iterdir()):
220+
if child.is_dir() and child.name.startswith("csep-synth-"):
221+
out.add(str(child / "SKILL.md"))
222+
return sorted(out)
223+
224+
225+
def _under_root(path: Path, root: Path) -> bool:
226+
"""Return whether path is under the current writable skills root."""
227+
try:
228+
return path.resolve(strict=False).is_relative_to(root)
229+
except OSError:
230+
return False
231+
232+
203233
def _non_synth_changed_paths(paths: list[str]) -> list[str]:
234+
"""Return changed skill docs outside the csep-synth namespace."""
204235
return sorted(
205236
path for path in paths
206237
if not Path(path).parent.name.startswith("csep-synth-")
207238
)
208239

209240

210241
def _validate_changed(run_id: str, changed: list[str]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
242+
"""Validate generated skill docs and maintain their invalid markers."""
211243
valid: list[dict[str, Any]] = []
212244
invalid: list[dict[str, Any]] = []
213245
retired: list[dict[str, Any]] = []

‎src/codex_self_evolution/skill_synthesis/validation.py‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,29 @@
1212

1313
RETIRED_DESCRIPTION = "Retired generated skill. Do not use."
1414
RETIRED_BODY_LINE = "Do not use this skill. It is kept only for audit history."
15+
_BLOCK_SCALAR_MARKERS = {"|", "|-", "|+", ">", ">-", ">+"}
16+
_ACTIVE_DESCRIPTION_PREFIXES = ("use when ", "this skill should be used when ")
17+
_WORKFLOW_HEADINGS = (
18+
"## when to use",
19+
"## inputs",
20+
"## workflow",
21+
"## verification",
22+
"## failure handling",
23+
)
24+
_SKILL_DECISION_MARKERS = (
25+
"## skill decision",
26+
"why this is a skill",
27+
"why not memory",
28+
"existing skill boundary",
29+
)
1530

1631

1732
def _now() -> str:
1833
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
1934

2035

2136
def validate_synth_skill(skill_path: Path) -> dict[str, Any]:
37+
"""Validate one generated csep-synth SKILL.md without rewriting its body."""
2238
if skill_path.name != "SKILL.md":
2339
return {"path": str(skill_path), "valid": False, "reason": "not_skill_md"}
2440
if not skill_path.parent.name.startswith(SYNTH_SKILL_PREFIX):
@@ -31,17 +47,32 @@ def validate_synth_skill(skill_path: Path) -> dict[str, Any]:
3147
meta = _frontmatter(text)
3248
if not meta.get("name") or not meta.get("description"):
3349
return {"path": str(skill_path), "valid": False, "reason": "missing_frontmatter"}
50+
if meta.get("name") != skill_path.parent.name:
51+
return {"path": str(skill_path), "valid": False, "reason": "name_mismatch"}
52+
description_raw = meta.get("description", "").strip()
53+
if description_raw in _BLOCK_SCALAR_MARKERS:
54+
return {"path": str(skill_path), "valid": False, "reason": "unsupported_description_format"}
55+
status = meta.get("csep_status", "")
56+
if status not in {"", "active", "retired"}:
57+
return {"path": str(skill_path), "valid": False, "reason": "invalid_status"}
3458
if contains_secret_like_text(text):
3559
return {"path": str(skill_path), "valid": False, "reason": "secret_like_content"}
36-
if meta.get("csep_status") == "retired":
37-
if meta.get("description") != RETIRED_DESCRIPTION:
60+
if status == "retired":
61+
if description_raw != RETIRED_DESCRIPTION:
3862
return {"path": str(skill_path), "valid": False, "reason": "invalid_retired_description"}
3963
if RETIRED_BODY_LINE not in text:
4064
return {"path": str(skill_path), "valid": False, "reason": "invalid_retired_body"}
4165
return {"path": str(skill_path), "valid": True, "status": "retired", "reason": None}
42-
description = meta.get("description", "").lower()
43-
if "use" not in description or "when" not in description:
66+
if RETIRED_BODY_LINE in text:
67+
return {"path": str(skill_path), "valid": False, "reason": "retired_body_without_status"}
68+
description = description_raw.lower()
69+
if not description.startswith(_ACTIVE_DESCRIPTION_PREFIXES):
4470
return {"path": str(skill_path), "valid": False, "reason": "weak_description"}
71+
body = text.lower()
72+
if not all(marker in body for marker in _SKILL_DECISION_MARKERS):
73+
return {"path": str(skill_path), "valid": False, "reason": "missing_skill_decision"}
74+
if not all(heading in body for heading in _WORKFLOW_HEADINGS):
75+
return {"path": str(skill_path), "valid": False, "reason": "missing_workflow_contract"}
4576
words = [word for word in re.split(r"\s+", text) if word]
4677
if len(words) < 24:
4778
return {"path": str(skill_path), "valid": False, "reason": "low_signal"}

‎tests/test_install_script.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_install_script_uses_uv_tool_and_cleans_only_managed_hooks(tmp_path):
100100
/ "cache"
101101
/ "codex-self-evolution"
102102
/ "codex-self-evolution"
103-
/ "0.7.7"
103+
/ "0.7.8"
104104
/ ".codex-plugin"
105105
/ "plugin.json"
106106
)

‎tests/test_skill_synthesis_agent.py‎

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010
)
1111

1212

13+
def _create_action(**overrides):
14+
action = {
15+
"action": "create",
16+
"skill_id": "csep-synth-alpha",
17+
"path": "/tmp/skills/csep-synth-alpha/SKILL.md",
18+
"evidence_keys": ["ev1"],
19+
"reason": "repeated workflow",
20+
"evidence_kind": "workflow",
21+
"why_skill_not_memory": "This is a reusable command workflow, not a static fact or preference.",
22+
"existing_skill_overlap": "No existing skill covers this exact trigger and command sequence.",
23+
}
24+
action.update(overrides)
25+
return action
26+
27+
1328
def test_prompt_names_workspace_and_allowed_skills_root(tmp_path: Path) -> None:
1429
prompt = build_skill_synthesis_prompt(
1530
run_input_dir=tmp_path / "run" / "input",
@@ -21,6 +36,14 @@ def test_prompt_names_workspace_and_allowed_skills_root(tmp_path: Path) -> None:
2136
assert str(tmp_path / "run" / "output" / "result.json") in prompt
2237
assert str(tmp_path / "skills" / "csep-synth-*") in prompt
2338
assert "Do not read files outside the input directory" in prompt
39+
assert '"schema_version": 1' in prompt
40+
assert '"actions": [' in prompt
41+
assert "Do not write legacy result shapes" in prompt
42+
assert "description: Use when" in prompt
43+
assert "Classify every candidate" in prompt
44+
assert "fact|rule|preference|workflow|duplicate|transient" in prompt
45+
assert "why_skill_not_memory" in prompt
46+
assert "existing_skill_overlap" in prompt
2447

2548

2649
def test_build_pi_command_uses_independent_provider_model() -> None:
@@ -48,11 +71,7 @@ def test_parse_agent_result_accepts_actions(tmp_path: Path) -> None:
4871
"run_id": "run-1",
4972
"actions": [
5073
{
51-
"action": "create",
52-
"skill_id": "csep-synth-alpha",
53-
"path": "/tmp/skills/csep-synth-alpha/SKILL.md",
54-
"evidence_keys": ["ev1"],
55-
"reason": "repeated workflow",
74+
**_create_action(),
5675
}
5776
],
5877
}), encoding="utf-8")
@@ -61,6 +80,7 @@ def test_parse_agent_result_accepts_actions(tmp_path: Path) -> None:
6180

6281
assert parsed["valid"] is True
6382
assert parsed["actions"][0]["skill_id"] == "csep-synth-alpha"
83+
assert parsed["actions"][0]["evidence_kind"] == "workflow"
6484

6585

6686
def test_parse_agent_result_marks_missing_or_invalid_partial(tmp_path: Path) -> None:
@@ -75,6 +95,40 @@ def test_parse_agent_result_marks_missing_or_invalid_partial(tmp_path: Path) ->
7595
assert invalid["result_invalid"] is True
7696

7797

98+
def test_parse_agent_result_rejects_publishable_action_without_skill_decision(tmp_path: Path) -> None:
99+
result_path = tmp_path / "result.json"
100+
result_path.write_text(json.dumps({
101+
"schema_version": 1,
102+
"run_id": "run-1",
103+
"actions": [
104+
_create_action(evidence_kind="", why_skill_not_memory="", existing_skill_overlap=""),
105+
],
106+
}), encoding="utf-8")
107+
108+
parsed = parse_agent_result(result_path)
109+
110+
assert parsed["valid"] is False
111+
assert parsed["result_invalid"] is True
112+
assert parsed["error"] == "missing_skill_decision"
113+
114+
115+
def test_parse_agent_result_rejects_publishable_non_workflow_action(tmp_path: Path) -> None:
116+
result_path = tmp_path / "result.json"
117+
result_path.write_text(json.dumps({
118+
"schema_version": 1,
119+
"run_id": "run-1",
120+
"actions": [
121+
_create_action(evidence_kind="rule"),
122+
],
123+
}), encoding="utf-8")
124+
125+
parsed = parse_agent_result(result_path)
126+
127+
assert parsed["valid"] is False
128+
assert parsed["result_invalid"] is True
129+
assert parsed["error"] == "non_workflow_skill"
130+
131+
78132
def test_parse_agent_result_rejects_non_synth_skill_id(tmp_path: Path) -> None:
79133
result_path = tmp_path / "result.json"
80134
result_path.write_text(json.dumps({

0 commit comments

Comments
 (0)