Skip to content
12 changes: 6 additions & 6 deletions docs/superpowers/specs/2026-05-27-evolve-skill-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ Claude Code 세션을 한참 돌리고 나면 같은 실수·우회·교정이
## 사용법

```
/me:evolve 현재 세션 회고
/me:evolve me:research 해당 스킬에 집중해 최근 세션 분석
/me:evolve --session <id> 특정 세션 ID 회고
/me:evolve --since 7d me:browse 7일치 + 스킬 필터
/me:evolve --dry-run 제안만 보고 적용 안 함
/me:evolve 현재 세션 회고 [구현됨]
/me:evolve --session <id> 특정 세션 ID 회고 [구현됨]
/me:evolve --dry-run 제안만 보고 적용 안 함 [구현됨, 메인 에이전트가 해석]
/me:evolve me:research 해당 스킬에 집중해 최근 세션 분석 [미구현]
/me:evolve --since 7d me:browse 7일치 + 스킬 필터 [미구현]
```

기본값(인자 없음)은 현재 세션. 인자에 스킬명이 있으면 해당 스킬이 등장한 세션·구간으로 좁힌다.
기본값(인자 없음)은 현재 세션. 스킬명·`--since` 필터는 설계 의도이며 build-index.ts에 아직 없다 (`parseArgs`는 `--session`과 jsonl 경로만 받음). 추가 시 build-index.ts·SKILL.md를 동기화할 것.

## 입력

Expand Down
31 changes: 13 additions & 18 deletions plugins/me/skills/evolve/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,17 @@ The main agent does two things in Phase 0:

2. **Build the index** — `build-index.ts` is read-only: auto-detects the transcript, extracts signals, prints JSON.

Use the `Base directory for this skill: <path>` value injected at the top of this command prompt — do **not** rely on `${CLAUDE_PLUGIN_ROOT}` (it is not guaranteed to be set in the slash-command shell).

```bash
bun "${CLAUDE_PLUGIN_ROOT}/skills/evolve/scripts/build-index.ts" [--session <id>]
bun "<Base directory>/scripts/build-index.ts" [--session <id>]
```

Exit codes: `0`=ok, `14`=transcript or project dir not found.
Exit codes: `0`=ok, `2`=unknown flag (see §4 below — `--dry-run` must not be passed here), `14`=transcript or project dir not found.

Capture stdout JSON into a variable. **Do not show it to the user** — pass it only to the next-step subagent.

If `events` is empty, or both `summary.clusters` and `summary.signal_positions` are empty, print "no improvement signals found in this session" and exit.
If `events` is empty, print "no improvement signals found in this session" and exit.

## Phase 1 — Subagent analysis (Agent)

Expand All @@ -60,7 +62,7 @@ Dispatch one subagent (`general-purpose`). The indexer hands user utterances ove
The prompt must include all of:

1. Spec path: `docs/superpowers/specs/2026-05-27-evolve-skill-design.md`
2. The full index JSON from Phase 0 (only `summary` + `events`). **Read `summary` first** — `headline` (one-line state), `clusters` (same-kind events within ≤30 turns, ≥3 occurrences, with `t_range`/`n`/`example_t`), `signal_positions` (per-kind turn coordinates). Use this to decide which regions to inspect, then slice `events[]` for causal-chain analysis. summary is a simple heuristic — false positives are expected; skip meaningless clusters.
2. The full index JSON from Phase 0 (`summary` + `events`). Read `summary.headline` (one-line state) and `summary.clusters` (same-kind events within ≤30 turns, ≥3 occurrences) first to spot dense regions, then walk `events[]` for causal-chain analysis. Clusters are a simple heuristic — skip meaningless ones.
3. **Classification task for `kind: "user"` events** — label each one with exactly one of (refer by array index, e.g. `events[12]`):
- **correction**: redirects/corrects the immediately preceding assistant action
- **success**: positive feedback on the preceding assistant action
Expand All @@ -70,19 +72,13 @@ The prompt must include all of:

Criterion is *the relation to the preceding assistant action (`event.prior`)*, not the words themselves. E.g. "stop and report" used as a directive verb is noise; "stop, that's wrong" reacting to a tool call is a correction.

4. Candidate-file mapping table (use alongside interrupt/repeat/error events):
4. Target-file selection — pick by *what kind of knowledge is missing*, not by signal pattern:

| Signal pattern | 1st choice | 2nd |
|---|---|---|
| many corrections → skill not invoked | that SKILL.md (description, triggers) | nearest AGENTS.md |
| correction → rule violated *after* skill invoked | that SKILL.md (body, Red Flags) | — |
| repeat + eventually finds X | nearest AGENTS.md (Key Files) | CLAUDE.md |
| correction → project rule/convention violated | nearest CLAUDE.md | AGENTS.md |
| success → a skill clearly worked well | that frequently-invoked SKILL.md (reinforce) | — |
| interrupt + prior action clearly wrong | that SKILL.md | AGENTS.md |
| repeated error → same tool failing | that SKILL.md (usage) | CLAUDE.md |
- **that SKILL.md** — skill triggers, body rules, Red Flags. Use when a skill should have run but didn't, or ran but violated its own contract.
- **nearest AGENTS.md** — repo navigation, file locations, "where to look" knowledge. Use when the agent searched/read repeatedly before finding something.
- **nearest CLAUDE.md** — project conventions and rules that govern *all* work in this tree.

**"Nearest" resolution**: "that SKILL.md" = the SKILL.md inferred from `events[]` items with `kind:"skill"` or from tool calls in `prior`. "Nearest AGENTS.md/CLAUDE.md" = walk up from that SKILL.md's directory; first one found wins (fallback to repo root). If no skill can be inferred, default to repo-root CLAUDE.md.
**"Nearest" resolution**: "that SKILL.md" = inferred from `events[]` items with `kind:"skill"` or tool calls in `prior`. "Nearest AGENTS.md/CLAUDE.md" = walk up from that SKILL.md's directory; first hit wins (fallback to repo-root CLAUDE.md). If no skill can be inferred, default to repo-root CLAUDE.md.

5. Output schema — one JSON block only, no other text. `event_index` is the integer position in the index's `events[]` array:

Expand All @@ -102,8 +98,7 @@ The prompt must include all of:
"patch": "<unified diff applicable with `git apply`>",
"rationale": "1-2 sentences"
}
],
"skipped": [{"event_index": 5, "reason": "low confidence"}]
]
}
```

Expand Down Expand Up @@ -133,7 +128,7 @@ After parsing the subagent's returned JSON:
- **edit**: let the user edit the patch, then proceed as `y`.
- **skip / n**: move on to the next proposal.

4. If `--dry-run` is passed, skip Phase 2 entirely and just print the proposal list.
4. If `--dry-run` is passed, skip Phase 2 entirely and just print the proposal list. `--dry-run` is consumed by the main agent only — never forward it to `build-index.ts` (the script will exit 2 on unknown flags).

5. Finalize: print the list of applied commit SHAs and the upstream-suggestions path (if any).

Expand Down
62 changes: 29 additions & 33 deletions plugins/me/skills/evolve/scripts/build-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ import { basename, join, resolve } from "node:path";
import { homedir } from "node:os";

// ── 타입 ───────────────────────────────────────────────
type EventKind = "user" | "skill" | "interrupt" | "error" | "agent" | "large_out" | "repeat";
type EventKind = "user" | "skill" | "interrupt" | "error" | "agent" | "repeat";

interface Event {
t: number;
kind: EventKind;
text?: string;
prior?: string[];
name?: string;
args?: string;
by?: "user" | "assistant";
tool?: string;
desc?: string;
sub?: string;
model?: string;
bytes?: number;
pattern?: string;
n?: number;
}
Expand All @@ -36,7 +36,6 @@ interface Cluster {
interface Summary {
headline: string;
clusters: Cluster[];
signal_positions: Partial<Record<EventKind, number[]>>;
}

interface SessionIndex {
Expand All @@ -51,7 +50,6 @@ interface SessionIndex {
interface ToolResultPayload {
content: string;
isError: boolean;
size: number;
}

interface Turn {
Expand Down Expand Up @@ -106,7 +104,7 @@ function loadTurns(jsonlPath: string): LoadedTranscript {
for (const c of content) {
if (c.type === "tool_result") {
const norm = normalizeToolResultContent(c.content);
t.toolResults.push({ content: norm, isError: c.is_error === true, size: norm.length });
t.toolResults.push({ content: norm, isError: c.is_error === true });
}
}
}
Expand All @@ -118,7 +116,7 @@ function loadTurns(jsonlPath: string): LoadedTranscript {
return { turns, sessionTitle };
}

// ── tool_use 요약 (user.prior, large_out.tool에 사용) ──
// ── tool_use 요약 (user.prior에 사용) ──
function summarizeToolUse(tu: { name: string; input: any }): string {
const name = tu.name;
let arg = "";
Expand Down Expand Up @@ -149,13 +147,25 @@ function priorAssistantActions(turns: Turn[], currentIdx: number): string[] {

// ── 슬래시 커맨드 검출 ──────────────────────────────────
const SLASH_CMD_TAG = /<command-name>\/([a-z0-9:_-]+)<\/command-name>/i;
const SLASH_CMD_PREFIX = /^\/([a-z0-9:_-]+)\b/i;
const SLASH_CMD_PREFIX = /^\/([a-z0-9:_-]+)\b(.*)$/i;
const SLASH_CMD_ARGS_TAG = /<command-args>([\s\S]*?)<\/command-args>/i;

function detectSlashCommand(userText: string): string | undefined {
interface DetectedSlash {
name: string;
args?: string;
}

function detectSlashCommand(userText: string): DetectedSlash | undefined {
const tag = userText.match(SLASH_CMD_TAG);
if (tag) return tag[1];
if (tag) {
const argsTag = userText.match(SLASH_CMD_ARGS_TAG);
const args = argsTag?.[1]?.trim();
return args ? { name: tag[1], args: args.slice(0, 200) } : { name: tag[1] };
}
const prefix = userText.trim().match(SLASH_CMD_PREFIX);
return prefix?.[1];
if (!prefix) return undefined;
const args = prefix[2]?.trim();
return args ? { name: prefix[1], args: args.slice(0, 200) } : { name: prefix[1] };
}

const PSEUDO_USER_PREFIXES = [
Expand All @@ -173,13 +183,11 @@ function isPseudoUser(userText: string): boolean {
}

// ── events 빌드 ────────────────────────────────────────
const LARGE_OUTPUT_THRESHOLD = 10 * 1024;

function buildEvents(turns: Turn[]): Event[] {
const events: Event[] = [];
const interruptClaimed = new Set<number>();

// user / skill / interrupt(user) / error / agent / large_out 를 순회 중에 emit
// user / skill / interrupt(user) / error / agent 를 순회 중에 emit
for (let i = 0; i < turns.length; i++) {
const t = turns[i];

Expand All @@ -188,9 +196,11 @@ function buildEvents(turns: Turn[]): Event[] {
// 단 사용자가 직접 친 슬래시 커맨드 (prefix form)는 user event도 함께 의미 있을 수 있지만
// 분류 노이즈를 줄이기 위해 skill만 emit.
if (t.userText) {
const slashName = detectSlashCommand(t.userText);
if (slashName) {
events.push({ t: t.index, kind: "skill", name: slashName });
const slash = detectSlashCommand(t.userText);
if (slash) {
const ev: Event = { t: t.index, kind: "skill", name: slash.name };
if (slash.args) ev.args = slash.args;
events.push(ev);
} else if (!isPseudoUser(t.userText)) {
events.push({
t: t.index,
Expand All @@ -205,16 +215,12 @@ function buildEvents(turns: Turn[]): Event[] {
events.push({ t: t.index, kind: "interrupt", by: t.interruptedBy ?? "user" });
interruptClaimed.add(t.index);
}
// tool_result 안의 error / large_out
// tool_result 안의 error
for (const tr of t.toolResults) {
if (!tr.isError) continue;
const prevAssist = [...turns.slice(0, i)].reverse().find((a) => a.type === "assistant" && a.toolUses.length > 0);
const toolName = prevAssist?.toolUses[prevAssist.toolUses.length - 1]?.name ?? "unknown";
if (tr.isError) {
events.push({ t: t.index, kind: "error", tool: toolName, text: tr.content.slice(0, 200) });
}
if (tr.size > LARGE_OUTPUT_THRESHOLD) {
events.push({ t: t.index, kind: "large_out", tool: toolName, bytes: tr.size });
}
events.push({ t: t.index, kind: "error", tool: toolName, text: tr.content.slice(0, 200) });
}
} else {
// assistant
Expand Down Expand Up @@ -297,15 +303,6 @@ function buildClusters(events: Event[]): Cluster[] {
return clusters.sort((a, b) => a.t_range[0] - b.t_range[0]);
}

function buildSignalPositions(events: Event[]): Partial<Record<EventKind, number[]>> {
const out: Partial<Record<EventKind, number[]>> = {};
for (const kind of SUMMARY_KINDS) {
const turns = events.filter((e) => e.kind === kind).map((e) => e.t);
if (turns.length > 0) out[kind] = turns;
}
return out;
}

function buildHeadline(turns: number, events: Event[], clusters: Cluster[]): string {
const counts: Record<string, number> = {};
for (const e of events) counts[e.kind] = (counts[e.kind] ?? 0) + 1;
Expand All @@ -322,7 +319,6 @@ function buildSummary(turns: number, events: Event[]): Summary {
return {
headline: buildHeadline(turns, events, clusters),
clusters,
signal_positions: buildSignalPositions(events),
};
}

Expand Down
28 changes: 8 additions & 20 deletions tests/me/evolve-build-index.bats
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ RICH_FIXTURE="${PROJECT_ROOT}/tests/fixtures/evolve/rich-signals-session.jsonl"
[ "$status" -eq 0 ]
# 인덱서는 분류 안 함 — user_correction/success_pattern 같은 kind는 절대 emit 안 함
echo "$output" | jq -e '
[.events[].kind] | all(. as $k | ["user","skill","interrupt","error","agent","large_out","repeat"] | index($k) != null)
[.events[].kind] | all(. as $k | ["user","skill","interrupt","error","agent","repeat"] | index($k) != null)
'
}

Expand All @@ -60,12 +60,13 @@ RICH_FIXTURE="${PROJECT_ROOT}/tests/fixtures/evolve/rich-signals-session.jsonl"
echo "$output" | jq -e '(keys - ["session_id","session_title","turns","summary","events"]) == []'
}

@test "evolve build-index: summary has headline, clusters, signal_positions" {
@test "evolve build-index: summary has headline and clusters" {
run bun "$INDEXER" "$FIXTURE"
[ "$status" -eq 0 ]
echo "$output" | jq -e '.summary.headline | type == "string"'
echo "$output" | jq -e '.summary.clusters | type == "array"'
echo "$output" | jq -e '.summary.signal_positions | type == "object"'
# signal_positions intentionally removed — clusters + events[] cover its role
echo "$output" | jq -e '.summary | has("signal_positions") | not'
}

@test "evolve build-index: detects slash-command in <command-name> tag form" {
Expand Down Expand Up @@ -119,24 +120,11 @@ RICH_FIXTURE="${PROJECT_ROOT}/tests/fixtures/evolve/rich-signals-session.jsonl"
'
}

@test "evolve build-index: detects large_out event above 10KB" {
@test "evolve build-index: large_out event kind is no longer emitted" {
run bun "$INDEXER" "$RICH_FIXTURE"
[ "$status" -eq 0 ]
echo "$output" | jq -e '
[.events[] | select(.kind == "large_out")] as $l
| ($l | length >= 1) and ($l[0].bytes > 10240)
'
}

@test "evolve build-index: signal_positions kinds appear in events" {
run bun "$INDEXER" "$RICH_FIXTURE"
[ "$status" -eq 0 ]
# 각 signal_positions 키는 실제 events 의 kind 와 일치해야 함
echo "$output" | jq -e '
(.summary.signal_positions | keys) as $sk
| ([.events[].kind] | unique) as $ek
| ($sk | all(. as $k | $ek | index($k) != null))
'
# large_out was removed because Phase 1's mapping table never consumed it.
echo "$output" | jq -e '[.events[] | select(.kind == "large_out")] | length == 0'
}

@test "evolve build-index: false-positive guard — body words do NOT trigger semantic kinds" {
Expand All @@ -148,5 +136,5 @@ EOF
[ "$status" -eq 0 ]
# user event 1건, 분류 kind 일체 없음
echo "$output" | jq -e '[.events[] | select(.kind == "user")] | length == 1'
echo "$output" | jq -e '[.events[].kind] | all(. as $k | ["user","skill","interrupt","error","agent","large_out","repeat"] | index($k) != null)'
echo "$output" | jq -e '[.events[].kind] | all(. as $k | ["user","skill","interrupt","error","agent","repeat"] | index($k) != null)'
}
Loading