Skip to content

Commit e7876d4

Browse files
committed
feat(claude-plugin): add synthesize-skill skill
A new evolve-lite skill that converts a saved trajectory into a reusable Claude Code skill (SKILL.md + supporting scripts), installed under both .evolve/skills/ (canonical) and .claude/skills/ (so Claude Code's skill loader picks it up). Models the SKILL.md shape on the existing learn skill: judgment lives in a forked subagent (read trajectory, identify the successful workflow, draft a SKILL.md and scripts), file-system plumbing lives in scripts/synthesize.py (frontmatter validation, dual writes, audit-log entry). Lives under plugin-source/_claude/ — claude-only for now since the dual write target (.claude/skills/) is platform-specific. Other platforms can adopt the same pattern with their own write paths in a follow-up. The skill is invoked manually for now; not wired into a Stop hook.
1 parent c57148b commit e7876d4

4 files changed

Lines changed: 604 additions & 0 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
---
2+
name: synthesize-skill
3+
description: Convert a saved trajectory into a reusable Claude Code skill (SKILL.md + supporting scripts) that future agents can invoke to skip rediscovered work. Use when a session captured a non-trivial workflow worth promoting from a free-text guideline to an executable skill.
4+
context: fork
5+
---
6+
7+
# Skill Synthesizer
8+
9+
## Overview
10+
11+
This skill reads a saved trajectory and produces a **reusable Claude Code skill** — a `SKILL.md` plus any supporting scripts — that captures the *successful* workflow the session discovered. The output goes to `.evolve/skills/<skill-name>/` (canonical, evolve-managed) and `.claude/skills/<skill-name>/` (so Claude Code's skills loader picks it up). Future sessions on the same project can then invoke the skill directly instead of re-deriving the workflow.
12+
13+
This is the **executable** counterpart to the `learn` skill's free-text guidelines: `learn` writes Markdown the next agent has to *read and decide what to do*; `synthesize-skill` writes a skill the next agent can simply *call*.
14+
15+
## When To Use
16+
17+
Use this skill when a trajectory captured:
18+
19+
- A **non-trivial workflow** that succeeded after trial-and-error (the eventual happy path is worth promoting from free-text advice to an invocable artifact).
20+
- A **reusable script or command sequence** the model wrote during the session — particularly one the agent had to reconstruct over multiple attempts.
21+
- An environment-specific workaround (a missing system tool, a permissions wrinkle, a fallback pipeline) that future sessions in the same project will hit.
22+
23+
Skip this skill — and let `learn` cover the case with a guideline alone — when:
24+
25+
- The successful path was a single trivial command.
26+
- The workflow embeds secrets, tokens, or one-off user inputs that can't be safely generalized.
27+
- A skill with the same trigger already exists in `.evolve/skills/` (use `learn`'s guideline path to refine the existing skill instead of creating a duplicate).
28+
29+
## Workflow
30+
31+
### Step 0: Locate the Trajectory
32+
33+
This skill runs in a forked context. **You cannot see the parent conversation directly** — read the trajectory the parent passed in via `args` or via the `Run /evolve-lite:synthesize-skill on <path>` instruction.
34+
35+
The trajectory path is either:
36+
37+
- supplied directly as `args` to the skill invocation, or
38+
- stated in the parent's invocation message as `The saved trajectory path is: <path>` — take everything after the colon, strip surrounding whitespace and quotes.
39+
40+
If neither is present, scan `.evolve/trajectories/` for the most recently modified `claude-transcript_<session-id>.jsonl` and use that. If `.evolve/trajectories/` does not exist or is empty, output zero artifacts and exit — do not invent a trajectory.
41+
42+
**Read the trajectory with the `Read` tool — do NOT shell out.** The transcript is JSONL: one JSON object per line. Filter for `"type": "assistant"` and `"type": "human"` records and reconstruct the flow from `message.content`.
43+
44+
### Step 1: Identify the Successful Workflow
45+
46+
Walk the trajectory and locate the **final, working** tool sequence — the one that actually produced the answer. Distinguish it from the trial-and-error leading up to it.
47+
48+
Capture:
49+
50+
- **What the user asked** (the original prompt).
51+
- **What ultimately worked** — the exact tool calls, scripts, or command sequences that produced the answer. Quote them verbatim from the trajectory.
52+
- **What didn't work** — the dead-ends. You will use these to write a `Triggers` section so the future agent knows when to reach for this skill *instead of* the failing approaches.
53+
- **Environment assumptions** — what was missing or had to be installed (e.g. "no exiftool, pip install Pillow needed").
54+
55+
If no clearly successful workflow is in the trajectory (the session ended without reaching an answer, or the answer came from a single trivial call), output zero artifacts and exit.
56+
57+
### Step 2: Decide a Skill Name and Trigger
58+
59+
The skill **name** must be:
60+
61+
- kebab-case, action-oriented (`extract-exif-metadata`, `parse-cloudwatch-logs`, `restart-stuck-deploy`)
62+
- specific enough that a future agent reading just the name can guess what it does
63+
- not a duplicate of any existing entry under `.evolve/skills/`
64+
65+
The skill **description** (one line, in the SKILL.md frontmatter) should describe the *task* the skill solves, not the trajectory it came from. Bad: "Solves the focal-length question from session abc123." Good: "Extract EXIF metadata (focal length, GPS, lens, timestamps) from JPEG/HEIC images using Pillow when system EXIF tools are unavailable."
66+
67+
The **trigger** (in the SKILL.md body, under `## When To Use`) should describe the broad task context, not the narrow original request — same rule as the `learn` skill's guidelines.
68+
69+
Before continuing, list `.evolve/skills/` (use the `Glob` tool, not `find` / `ls`) and confirm your chosen name does not collide with an existing skill.
70+
71+
### Step 3: Draft the SKILL.md
72+
73+
Author a SKILL.md with this exact frontmatter shape — the validator in Step 5 will reject it otherwise:
74+
75+
```
76+
---
77+
name: <kebab-case-name>
78+
description: <one-line task description>
79+
---
80+
81+
# <Title Case Name>
82+
83+
## Overview
84+
<1–2 sentences: what the skill does and when to use it>
85+
86+
## When To Use
87+
- <trigger 1>
88+
- <trigger 2>
89+
90+
## Workflow
91+
<step-by-step instructions for the agent>
92+
```
93+
94+
Notes:
95+
96+
- `context: fork` is **omitted** for synthesized skills. They run in the parent context so they can write files into the workspace and report back.
97+
- Do NOT inline the full successful script into the SKILL.md if it's more than ~10 lines — put it in a sibling `scripts/` file (Step 4) and reference it from the SKILL.md.
98+
- The Workflow section should describe what to do *to solve the task*, not retell the original session. A future agent reading this should be able to act without ever seeing the trajectory.
99+
100+
### Step 4: Emit Supporting Scripts
101+
102+
If the successful workflow used a non-trivial script (more than a one-liner), write it as a sibling file under `scripts/` of your draft skill directory. Use the **already-validated code from the trajectory** — do not invent variations. Strip incidental one-off inputs (literal file names, IDs, hard-coded outputs) and replace with arguments or stdin where appropriate.
103+
104+
Common shape:
105+
106+
```
107+
.evolve/skills/<name>/
108+
├── SKILL.md
109+
└── scripts/
110+
└── <action>.py # callable as `python3 scripts/<action>.py <args>`
111+
```
112+
113+
If the workflow was a sequence of shell commands rather than a script, encode it as an executable shell script (`scripts/<action>.sh`) so future agents can invoke it as a single unit instead of replaying each command.
114+
115+
If no non-trivial script is needed (the workflow is a sequence of standard tool calls), skip this step — the SKILL.md alone is the skill.
116+
117+
### Step 5: Finalize
118+
119+
Place your draft files (SKILL.md and any scripts) under a temporary directory inside the workspace, e.g. `/tmp/synthesized-<name>/`, then call:
120+
121+
```bash
122+
python3 ${CLAUDE_PLUGIN_ROOT}/skills/evolve-lite/synthesize-skill/scripts/synthesize.py finalize \
123+
--src /tmp/synthesized-<name>/ \
124+
--name <kebab-case-name> \
125+
--trajectory <saved_trajectory_path>
126+
```
127+
128+
The script will:
129+
130+
- Validate the SKILL.md frontmatter (`name` and `description` required; `name` must match `--name`).
131+
- Reject the skill if a same-named skill already exists in `.evolve/skills/` (overwriting requires `--force`).
132+
- Copy the directory into both `.evolve/skills/<name>/` (canonical) and `.claude/skills/<name>/` (so Claude Code's skills loader sees it).
133+
- Append a `synthesize_skill` event to `.evolve/audit.log` recording the new skill, the source trajectory, and the timestamp.
134+
- Print the two destination paths.
135+
136+
If the validator rejects the draft, fix the SKILL.md and retry — do not edit files in `.evolve/skills/` or `.claude/skills/` directly.
137+
138+
### Step 6: Confirm
139+
140+
After the script returns, list the destination directories with the `Glob` tool to confirm the files landed. Output a short summary:
141+
142+
- The skill name and description.
143+
- The destination paths.
144+
- A one-line note on what future sessions should now be able to do that they couldn't before.
145+
146+
## Best Practices
147+
148+
1. **One skill per workflow.** If the trajectory contains two unrelated successful workflows, run synthesis twice with different names — do not pack them into one skill.
149+
2. **Cite the trajectory.** Include the `--trajectory` flag so the audit log records provenance; future maintainers can trace the skill back to the session that produced it.
150+
3. **Don't promote one-shots.** A skill is worth synthesizing only if the trigger is plausibly recurring. If the trajectory looks like a one-off, prefer the `learn` skill's guideline path instead.
151+
4. **Don't paraphrase failure.** The skill describes what *worked*. If you find yourself writing "this skill avoids the problem where exiftool isn't installed," restate it as "uses Pillow to extract EXIF; works in environments without system EXIF tools." Triggers describe *when*, not *what failed*.
152+
5. **Keep scripts minimal.** Strip incidental log lines, debug prints, and validation that wasn't actually exercised in the trajectory. If a feature wasn't validated, leave it out.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env python3
2+
"""Synthesize-skill helper: validate and install a synthesized skill.
3+
4+
The synthesize-skill skill (a subagent) is responsible for the *judgment* —
5+
reading the trajectory, identifying the successful workflow, and writing
6+
draft SKILL.md + supporting scripts into a temporary directory.
7+
8+
This script is the *plumbing* — it validates the draft frontmatter, copies
9+
the directory into both the canonical evolve-managed location and the
10+
platform-specific skills loader location, and writes an audit-log entry.
11+
12+
Usage:
13+
synthesize.py finalize --src <draft_dir> --name <kebab-case-name> \
14+
[--trajectory <path>] [--workspace <path>] [--force]
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import argparse
20+
import re
21+
import shutil
22+
import sys
23+
from pathlib import Path
24+
25+
# Reuse the plugin's lib helpers (audit-log writer + entities-dir locator).
26+
_script = Path(__file__).resolve()
27+
_lib = None
28+
for _ancestor in _script.parents:
29+
for _candidate in (_ancestor / "lib", _ancestor / "evolve-lib"):
30+
if (_candidate / "audit.py").is_file():
31+
_lib = _candidate
32+
break
33+
if _lib is not None:
34+
break
35+
if _lib is None:
36+
raise ImportError(f"Cannot find plugin lib directory above {_script}")
37+
sys.path.insert(0, str(_lib))
38+
from audit import append as audit_append # noqa: E402
39+
40+
41+
KEBAB_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
42+
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
43+
44+
45+
def _parse_frontmatter(skill_md: Path) -> tuple[dict[str, str], str]:
46+
"""Minimal YAML-ish frontmatter parser. Supports `key: value` lines only.
47+
48+
Returns (frontmatter_dict, body_text).
49+
"""
50+
text = skill_md.read_text(encoding="utf-8")
51+
match = FRONTMATTER_RE.match(text)
52+
if not match:
53+
raise ValueError(f"{skill_md} has no frontmatter block")
54+
fm: dict[str, str] = {}
55+
for line in match.group(1).splitlines():
56+
line = line.strip()
57+
if not line or line.startswith("#"):
58+
continue
59+
if ":" not in line:
60+
raise ValueError(f"{skill_md}: malformed frontmatter line: {line!r}")
61+
key, _, value = line.partition(":")
62+
fm[key.strip()] = value.strip().strip('"').strip("'")
63+
return fm, text[match.end():]
64+
65+
66+
def _validate_draft(src: Path, name: str) -> None:
67+
if not KEBAB_RE.match(name):
68+
raise SystemExit(f"--name {name!r} is not kebab-case")
69+
if not src.is_dir():
70+
raise SystemExit(f"--src {src} is not a directory")
71+
skill_md = src / "SKILL.md"
72+
if not skill_md.is_file():
73+
raise SystemExit(f"missing SKILL.md in {src}")
74+
75+
fm, body = _parse_frontmatter(skill_md)
76+
if "name" not in fm or "description" not in fm:
77+
raise SystemExit(f"SKILL.md frontmatter must include `name` and `description` (got: {sorted(fm.keys())})")
78+
if fm["name"] != name:
79+
raise SystemExit(f"frontmatter name {fm['name']!r} does not match --name {name!r}")
80+
if not fm["description"]:
81+
raise SystemExit("SKILL.md description is empty")
82+
if len(body.strip()) < 50:
83+
raise SystemExit("SKILL.md body is suspiciously short — not enough instructions to be useful")
84+
85+
86+
def _resolve_workspace(arg: str | None) -> Path:
87+
if arg:
88+
return Path(arg).resolve()
89+
# Fall back to the current working directory; at runtime this is the
90+
# workspace mounted into the sandbox (`/workspace`) or the host repo root.
91+
return Path.cwd().resolve()
92+
93+
94+
def _copy_into(src: Path, dst: Path, force: bool) -> None:
95+
if dst.exists():
96+
if not force:
97+
raise SystemExit(f"{dst} already exists (use --force to overwrite)")
98+
shutil.rmtree(dst)
99+
shutil.copytree(src, dst)
100+
101+
102+
def cmd_finalize(args: argparse.Namespace) -> int:
103+
src = Path(args.src).resolve()
104+
name = args.name
105+
workspace = _resolve_workspace(args.workspace)
106+
107+
_validate_draft(src, name)
108+
109+
evolve_dst = workspace / ".evolve" / "skills" / name
110+
claude_dst = workspace / ".claude" / "skills" / name
111+
112+
_copy_into(src, evolve_dst, args.force)
113+
_copy_into(src, claude_dst, args.force)
114+
115+
audit_append(
116+
project_root=str(workspace),
117+
event="synthesize_skill",
118+
skill=name,
119+
evolve_path=str(evolve_dst.relative_to(workspace)),
120+
claude_path=str(claude_dst.relative_to(workspace)),
121+
trajectory=args.trajectory or "",
122+
)
123+
124+
print(f"Installed skill {name!r}:")
125+
print(f" evolve: {evolve_dst}")
126+
print(f" claude: {claude_dst}")
127+
return 0
128+
129+
130+
def main(argv: list[str] | None = None) -> int:
131+
parser = argparse.ArgumentParser(description=__doc__)
132+
sub = parser.add_subparsers(dest="cmd", required=True)
133+
134+
p_finalize = sub.add_parser(
135+
"finalize",
136+
help="Validate a draft skill directory and install it under .evolve/skills/ and .claude/skills/.",
137+
)
138+
p_finalize.add_argument("--src", required=True, help="Draft directory containing SKILL.md and any scripts/")
139+
p_finalize.add_argument("--name", required=True, help="Kebab-case skill name; must match SKILL.md frontmatter")
140+
p_finalize.add_argument("--trajectory", default="", help="Source trajectory path (recorded in audit.log)")
141+
p_finalize.add_argument("--workspace", default=None, help="Project root (defaults to CWD)")
142+
p_finalize.add_argument("--force", action="store_true", help="Overwrite existing skill of the same name")
143+
p_finalize.set_defaults(func=cmd_finalize)
144+
145+
args = parser.parse_args(argv)
146+
return args.func(args)
147+
148+
149+
if __name__ == "__main__":
150+
raise SystemExit(main())

0 commit comments

Comments
 (0)