Skip to content

Commit b7d7f79

Browse files
committed
Add SuggestSkills: decide which skills to build from your work history
Signed-off-by: Radek <radek@dataminelab.com>
1 parent 58381b3 commit b7d7f79

3 files changed

Lines changed: 312 additions & 0 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
name: SuggestSkills
3+
description: "Discover WHICH new skills you should create, from your own work history plus your satisfaction/frustration signals. Read-only and proposal-only: it surfaces recurring pain that no existing skill, loop, or workflow covers, then hands you a ranked shortlist to build with CreateSkill. It never creates or edits a skill itself. Frustration is a first-class signal (a topic can look 'covered' while you keep hitting the same wall inside it), so it reads low ratings and recurrence markers, not just session topics. USE WHEN should I create a skill, what skills do I need, suggest skills, skill gap, based on my recent work, am I missing a skill, what should I build. NOT FOR creating/validating/testing/optimizing an individual skill (use CreateSkill) — this only decides WHAT to build, not how."
4+
---
5+
6+
# SuggestSkills — what should I build next?
7+
8+
A read-only analytics pass over your own work. It answers one question: given what you have actually been doing and where you have been frustrated, is there a recurring problem that deserves its own skill and does not have one yet? It proposes; you decide; `CreateSkill` builds. It has no capability to create or edit a skill, by design.
9+
10+
## Why it is separate from CreateSkill
11+
12+
Discovery is read-only; creation mutates. Keeping the two apart is the permission boundary that makes "never auto-create" real rather than a promise in prose: this skill cannot write a skill even if asked.
13+
14+
## The two blind spots it exists to defeat
15+
16+
1. **Frustration is invisible to topic-matching.** A topic can be nominally covered by a build/test skill while you keep hitting the same wall inside it. Low ratings and "regressed again" recurrence are the strongest signal a skill is missing. Weight them above raw topic frequency.
17+
2. **Discipline gaps hide under covered topics.** "App development" maps to a build skill, but the recurring pain may be an unowned discipline (state modeling, error handling, migration safety) that the build skill never addresses. Coverage means the discipline is genuinely handled, not that the topic shares a keyword.
18+
19+
## Workflow
20+
21+
`Workflows/Scan.md` — the full pass. In short:
22+
23+
1. **Gather deterministically.** Run `Tools/CollectSignals.ts` to emit a normalized corpus (recent sessions, low-rating frustrations with sentiment, and the skill/loop/workflow registry for dedup, plus warnings for any missing or malformed store). The LLM does not gather; it only judges what the tool returns, so two runs see the same evidence.
24+
2. **Cluster by pain.** Group the corpus into recurring themes, carrying both how often each recurs AND how much frustration it drew.
25+
3. **Dedup against real coverage.** For each candidate, read the bodies of the skills/loops/workflows that might cover it. Name-match is not coverage; the covering unit must actually address the failure class.
26+
4. **Verify with two independent passes, report the UNION.** Do not require both passes to agree before surfacing a gap (strict intersection suppresses exactly the subtle discipline gaps this exists to find). Report every gap either pass flags, tagged with its agreement level (both = high confidence, one = needs review).
27+
5. **Propose, never create.** Emit a ranked shortlist with evidence (session count, frustration count, the specific recurring failure) to a review location or the session summary. Route accepted proposals to `CreateSkill`. Redact secrets, client names, and personal paths from anything written out.
28+
29+
## Gotchas
30+
31+
- **A clean topic-coverage result with dirty frustration signals is a FALSE negative.** If the ratings show recurring frustration in an area you marked covered, re-open it — the discipline under that topic is the gap. (This is the exact failure this skill was built to fix.)
32+
- **Recurrence is severity-weighted, not a bare count.** Three trivial sessions matter less than one long, painful, repeated migration. A high-severity pain that recurs across a few sessions qualifies even below an arbitrary threshold.
33+
- **Behavior is not a skill.** "Too verbose", "misread scope", "repeated a reminder" are steering/feedback, not skill gaps. Separate them out and route them to memory/preferences, not to CreateSkill.
34+
- **Gathering is deterministic on purpose.** If you find yourself grepping stores by hand in the workflow, use the tool instead — hand-gathering makes runs non-reproducible and the eval meaningless.
35+
- **Paths are discovered, not assumed.** The tool resolves stores via flags/env/root, so it works across installs; do not hardcode a home directory.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* CollectSignals — deterministic, read-only signal collector for skill-gap discovery.
4+
*
5+
* Emits a normalized corpus (JSON to stdout) so the LLM step only clusters and judges;
6+
* it never gathers. Run at a fixed point in time over unchanged stores, it is deterministic
7+
* (output ordering is fully tie-broken). Writes nothing.
8+
*
9+
* Paths are DISCOVERED, never hardcoded to a person's home. Resolution order per store:
10+
* 1. explicit CLI flag 2. env var 3. a conventional default under --root
11+
* An explicit flag pointing at a nonexistent path is reported (never silently replaced by a
12+
* default). Anything missing or malformed is reported in `warnings`/`missing`, never fatal.
13+
*
14+
* Usage:
15+
* bun CollectSignals.ts [--root <dir>] [--days <n>] [--max-rating <n>]
16+
* [--ratings <file>] [--work <dir>] [--skills <dir>] [--loops <dir>]
17+
* env: SKILLSCAN_MEMORY_ROOT, SKILLSCAN_RATINGS_FILE, SKILLSCAN_WORK_DIR,
18+
* SKILLSCAN_SKILLS_DIR, SKILLSCAN_LOOPS_DIR
19+
*
20+
* Contract (stdout JSON):
21+
* {
22+
* window: { days, since },
23+
* sessions: { slug, mtime }[], // recent work-session dirs (. and _ prefixed skipped as system dirs)
24+
* frustrations: { date, rating, note }[], // low ratings + sentiment, sorted
25+
* registries: { kind: "skill"|"loop"|"workflow", name, description }[], // for dedup; bodies read by the LLM step
26+
* warnings: string[], // malformed rows, unreadable/oversized files (non-fatal)
27+
* missing: string[], // stores that don't exist in this install
28+
* sources: Record<string,string> // resolved paths actually used
29+
* }
30+
* Exit 0 even when stores are missing (a fresh install is a valid, empty corpus);
31+
* non-zero only on an unexpected internal error.
32+
*/
33+
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
34+
import { join, basename } from "node:path";
35+
36+
const MAX_RATINGS_BYTES = 10_000_000; // guard against a huge/FIFO ratings store
37+
const MAX_NOTE_CHARS = 500;
38+
39+
type Session = { slug: string; mtime: string };
40+
type Frustration = { date: string; rating: number; note: string };
41+
type RegistryEntry = { kind: "skill" | "loop" | "workflow"; name: string; description: string };
42+
type Corpus = {
43+
window: { days: number; since: string };
44+
sessions: Session[];
45+
frustrations: Frustration[];
46+
registries: RegistryEntry[];
47+
warnings: string[];
48+
missing: string[];
49+
sources: Record<string, string>;
50+
};
51+
52+
/** Value for `flag`, or undefined. A following token that is itself a flag (starts with "-") is NOT a value. */
53+
function arg(flag: string): string | undefined {
54+
const i = process.argv.indexOf(flag);
55+
if (i < 0 || i + 1 >= process.argv.length) return undefined;
56+
const v = process.argv[i + 1];
57+
return v.startsWith("-") ? undefined : v;
58+
}
59+
60+
/** Strict integer flag, clamped to [min,max]; warns (never silently accepts garbage or out-of-range). */
61+
function intArg(flag: string, dflt: number, min: number, max: number, warnings: string[]): number {
62+
const v = arg(flag);
63+
if (v === undefined) return dflt;
64+
if (!/^-?\d+$/.test(v)) { warnings.push(`${flag}: not an integer (${v}); using ${dflt}`); return dflt; }
65+
let n = Number.parseInt(v, 10);
66+
if (n < min) { warnings.push(`${flag}: ${n} below ${min}; clamped`); n = min; }
67+
else if (n > max) { warnings.push(`${flag}: ${n} above ${max}; clamped`); n = max; }
68+
return n;
69+
}
70+
71+
/**
72+
* Resolve a store path: explicit flag > env var > default under root.
73+
* An explicit path that does not exist is a warning + null (never silently falls through to a default).
74+
* `defaultRel === null` means the store has no conventional default (opt-in only, e.g. loops).
75+
*/
76+
function resolveStore(
77+
explicit: string | undefined,
78+
envVar: string,
79+
defaultRel: string | null,
80+
root: string,
81+
label: string,
82+
warnings: string[],
83+
): string | null {
84+
if (explicit !== undefined) {
85+
if (existsSync(explicit)) return explicit;
86+
warnings.push(`${label}: --${label} path ${explicit} does not exist`);
87+
return null;
88+
}
89+
const env = process.env[envVar];
90+
if (env && existsSync(env)) return env;
91+
if (defaultRel) { const d = join(root, defaultRel); if (existsSync(d)) return d; }
92+
return null;
93+
}
94+
95+
function parseFrustrations(file: string | null, maxRating: number, since: Date, warnings: string[], missing: string[]): Frustration[] {
96+
if (!file) { missing.push("ratings"); return []; }
97+
try {
98+
const st = statSync(file);
99+
if (!st.isFile()) { warnings.push(`ratings: not a regular file`); return []; }
100+
if (st.size > MAX_RATINGS_BYTES) { warnings.push(`ratings: too large (${st.size} bytes), skipped`); return []; }
101+
} catch (e) { warnings.push(`ratings unreadable: ${(e as Error).message}`); return []; }
102+
103+
let raw: string;
104+
try { raw = readFileSync(file, "utf8"); } catch (e) { warnings.push(`ratings unreadable: ${(e as Error).message}`); return []; }
105+
106+
const out: Frustration[] = [];
107+
let bad = 0;
108+
for (const line of raw.split("\n")) {
109+
const t = line.trim();
110+
if (!t) continue;
111+
let j: unknown;
112+
try { j = JSON.parse(t); } catch { bad++; continue; }
113+
const r = j as Record<string, unknown>;
114+
const rating = typeof r.rating === "number" ? r.rating : NaN;
115+
const ts = typeof r.timestamp === "string" ? r.timestamp : "";
116+
if (!Number.isFinite(rating) || !ts) { bad++; continue; }
117+
if (rating > maxRating) continue;
118+
const ms = new Date(ts).getTime();
119+
if (Number.isNaN(ms)) { bad++; continue; } // unparseable timestamp = malformed, not "out of window"
120+
if (ms < since.getTime()) continue;
121+
const note = (typeof r.sentiment_summary === "string" ? r.sentiment_summary : "")
122+
.replace(/[\u0000-\u001f\u007f]/g, " ")
123+
.slice(0, MAX_NOTE_CHARS);
124+
out.push({ date: new Date(ms).toISOString().slice(0, 10), rating, note });
125+
}
126+
if (bad > 0) warnings.push(`ratings: skipped ${bad} malformed/incomplete line(s)`);
127+
return out.sort((a, b) => a.rating - b.rating || a.date.localeCompare(b.date) || a.note.localeCompare(b.note));
128+
}
129+
130+
function collectSessions(dir: string | null, since: Date, warnings: string[], missing: string[]): Session[] {
131+
if (!dir) { missing.push("work-sessions"); return []; }
132+
let names: string[];
133+
try { names = readdirSync(dir); } catch (e) { warnings.push(`work dir unreadable: ${(e as Error).message}`); return []; }
134+
const out: Session[] = [];
135+
for (const name of names) {
136+
if (name.startsWith(".") || name.startsWith("_")) continue; // skip hidden / system dirs
137+
try {
138+
const st = statSync(join(dir, name));
139+
if (st.isDirectory() && st.mtime.getTime() >= since.getTime()) out.push({ slug: name, mtime: st.mtime.toISOString().slice(0, 10) });
140+
} catch { /* transient race on a dir entry — skip, don't fail the run */ }
141+
}
142+
return out.sort((a, b) => b.mtime.localeCompare(a.mtime) || a.slug.localeCompare(b.slug));
143+
}
144+
145+
/**
146+
* name + description from a SKILL.md's YAML frontmatter block. Handles folded/block scalars
147+
* (`description: >` / `|`) by gathering the indented continuation lines. Returns null if there
148+
* is no frontmatter `name:`.
149+
*/
150+
function readMeta(skillMd: string): { name: string; description: string } | null {
151+
let txt: string;
152+
try { txt = readFileSync(skillMd, "utf8"); } catch { return null; }
153+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(txt);
154+
const block = fm ? fm[1] : txt;
155+
const name = /^name:\s*(.+)$/m.exec(block)?.[1]?.trim();
156+
if (!name) return null;
157+
158+
let desc = /^description:\s*(.+)$/m.exec(block)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
159+
if (/^[>|][+-]?\d*$/.test(desc)) { // block scalar indicator — gather indented continuation
160+
const lines = block.split("\n");
161+
const di = lines.findIndex((l) => /^description:\s*[>|]/.test(l));
162+
const cont: string[] = [];
163+
for (let k = di + 1; k < lines.length; k++) {
164+
if (/^\s+\S/.test(lines[k])) cont.push(lines[k].trim());
165+
else break;
166+
}
167+
desc = cont.join(" ");
168+
}
169+
return { name, description: desc };
170+
}
171+
172+
function collectRegistries(skillsDir: string | null, loopsDir: string | null, warnings: string[], missing: string[]): RegistryEntry[] {
173+
const out: RegistryEntry[] = [];
174+
if (!skillsDir) { missing.push("skills"); }
175+
else {
176+
let dirs: string[] = [];
177+
try { dirs = readdirSync(skillsDir); } catch (e) { warnings.push(`skills dir unreadable: ${(e as Error).message}`); }
178+
for (const d of dirs) {
179+
const md = join(skillsDir, d, "SKILL.md");
180+
if (!existsSync(md)) continue;
181+
const meta = readMeta(md);
182+
if (!meta) { warnings.push(`skills: ${d}/SKILL.md unreadable or missing name:`); continue; }
183+
out.push({ kind: "skill", name: meta.name, description: meta.description });
184+
const wf = join(skillsDir, d, "Workflows"); // workflows are reusable coverage too
185+
if (existsSync(wf)) {
186+
try { for (const f of readdirSync(wf)) if (f.endsWith(".md")) out.push({ kind: "workflow", name: `${meta.name}/${basename(f, ".md")}`, description: "" }); } catch { /* skip */ }
187+
}
188+
}
189+
}
190+
if (loopsDir) {
191+
try { for (const f of readdirSync(loopsDir)) if (f.endsWith(".md")) out.push({ kind: "loop", name: basename(f, ".md"), description: "" }); }
192+
catch (e) { warnings.push(`loops dir unreadable: ${(e as Error).message}`); }
193+
}
194+
return out.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
195+
}
196+
197+
function main(): void {
198+
const warnings: string[] = [];
199+
const missing: string[] = [];
200+
201+
const root = arg("--root") ?? process.env.SKILLSCAN_MEMORY_ROOT ?? join(process.env.HOME ?? ".", ".claude");
202+
const days = intArg("--days", 45, 1, 3650, warnings);
203+
const maxRating = intArg("--max-rating", 4, 1, 10, warnings);
204+
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
205+
206+
const ratingsFile = resolveStore(arg("--ratings"), "SKILLSCAN_RATINGS_FILE", "MEMORY/LEARNING/SIGNALS/ratings.jsonl", root, "ratings", warnings);
207+
const workDir = resolveStore(arg("--work"), "SKILLSCAN_WORK_DIR", "MEMORY/WORK", root, "work", warnings);
208+
const skillsDir = resolveStore(arg("--skills"), "SKILLSCAN_SKILLS_DIR", "skills", root, "skills", warnings);
209+
const loopsDir = resolveStore(arg("--loops"), "SKILLSCAN_LOOPS_DIR", null, root, "loops", warnings); // opt-in; no personal default
210+
211+
const corpus: Corpus = {
212+
window: { days, since: since.toISOString().slice(0, 10) },
213+
sessions: collectSessions(workDir, since, warnings, missing),
214+
frustrations: parseFrustrations(ratingsFile, maxRating, since, warnings, missing),
215+
registries: collectRegistries(skillsDir, loopsDir, warnings, missing),
216+
warnings,
217+
missing,
218+
sources: {
219+
root,
220+
ratings: ratingsFile ?? "(none)",
221+
work: workDir ?? "(none)",
222+
skills: skillsDir ?? "(none)",
223+
loops: loopsDir ?? "(none)",
224+
},
225+
};
226+
227+
process.stdout.write(JSON.stringify(corpus, null, 2) + "\n");
228+
}
229+
230+
main();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Scan — discover skill gaps from work history + frustration
2+
3+
Read-only. Proposes only. Never creates a skill (that is CreateSkill, after you approve).
4+
5+
## Step 1 — gather deterministically (tool, not prose)
6+
7+
```bash
8+
bun Tools/CollectSignals.ts --days 45 > /tmp/skill-scan-corpus.json
9+
# flags: --root <dir> --ratings <file> --work <dir> --skills <dir> --loops <dir> --max-rating <n>
10+
# resolves each store via flag > env > default under --root (env: SKILLSCAN_MEMORY_ROOT for --root,
11+
# then SKILLSCAN_RATINGS_FILE / SKILLSCAN_WORK_DIR / SKILLSCAN_SKILLS_DIR / SKILLSCAN_LOOPS_DIR).
12+
# loops is opt-in: it has no default, so pass --loops (or SKILLSCAN_LOOPS_DIR) if your setup has a loop catalog.
13+
```
14+
15+
The corpus is `{ window, sessions[], frustrations[], registries[], warnings[], missing[], sources }`. Read `warnings`/`missing` first: a missing ratings store means the frustration signal is unavailable this run (say so in the report rather than pretending the topic-only result is complete). Do not re-gather by hand; the tool is the single source of evidence so two runs judge the same corpus.
16+
17+
## Step 2 — cluster by pain
18+
19+
Group `sessions` + `frustrations` into recurring themes. For each theme carry two numbers: recurrence (how many sessions) and friction (how many low ratings / recurrence markers like "regressed again"). Frustration outweighs raw topic frequency.
20+
21+
## Step 3 — classify each cluster
22+
23+
- **BEHAVIOR-FEEDBACK** — verbosity, scope misreads, reminder cadence. Not a skill; route to memory/preferences. Exclude.
24+
- **COVERED** — an existing skill/loop/workflow genuinely handles the *discipline*. Confirm by reading the covering unit's body, not its name; map the specific failure class to explicit guidance in it. If the body does not address the failure, it is not covered.
25+
- **GAP** — recurs (severity-weighted; a high-severity repeated pain qualifies even below ~3) AND uncovered, INCLUDING a discipline gap under a topic a build/test skill nominally covers.
26+
27+
## Step 4 — verify with two independent passes, report the UNION
28+
29+
Spawn two agents that classify the clusters from the same corpus. Report every cluster either flags as GAP, tagged by agreement: `both` (high confidence) or `one` (needs review). Do NOT drop single-pass gaps — strict intersection hides the subtle discipline gaps this skill exists to surface.
30+
31+
## Step 5 — propose, never create
32+
33+
Emit a ranked shortlist. Each proposal: name, one-line description, and evidence (recurrence, friction, the specific recurring failure it would prevent). Redact secrets, client/project names, and personal paths from anything written to a review location. Accepted proposals go to `CreateSkill` as a separate, human-approved step. This workflow writes no skill.
34+
35+
## Output
36+
37+
```
38+
## Skill-gap scan (last N days, M sessions; frustration store: present/absent)
39+
### Gaps worth building
40+
- <Name> [confidence: both|one] — <desc>. Evidence: N sessions, K frustration signals, recurring failure = "<...>". → CreateSkill?
41+
### Covered (verified against bodies, no action)
42+
- <theme> → <skill/loop/workflow>
43+
### Behavior-feedback (route to memory, not a skill)
44+
- <theme>
45+
### Recommendation
46+
<1-2 sentences; "nothing new" is valid ONLY when the frustration signals are also clean>
47+
```

0 commit comments

Comments
 (0)