skills-atlas-cli 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/skills.js CHANGED
@@ -16,7 +16,9 @@ const suggest = require('../src/commands/suggest');
16
16
  const hook = require('../src/commands/hook');
17
17
  const gaps = require('../src/commands/gaps');
18
18
  const gapAnalyze = require('../src/commands/gap-analyze');
19
+ const craft = require('../src/commands/craft');
19
20
  const prune = require('../src/commands/prune');
21
+ const feedback = require('../src/commands/feedback');
20
22
  const update = require('../src/commands/update');
21
23
  const mcp = require('../src/commands/mcp');
22
24
  const { categories, list } = require('../src/commands/categories');
@@ -24,7 +26,7 @@ const { categories, list } = require('../src/commands/categories');
24
26
  const VERSION = require('../package.json').version;
25
27
  // `use` = install + activate inline (emit the SKILL.md so an agent follows it now).
26
28
  const use = argv => install([...argv, '--inline']);
27
- const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, 'gap-analyze': gapAnalyze, prune, update, categories, list, registry, mcp };
29
+ const commands = { search, info, install, use, kit, sync, installed, upgrade, remove, outdated, doctor, suggest, hook, gaps, 'gap-analyze': gapAnalyze, craft, prune, feedback, update, categories, list, registry, mcp };
28
30
 
29
31
  const HELP = `skills-atlas — search, install & manage AI agent skills
30
32
 
@@ -48,7 +50,9 @@ manage what you've installed:
48
50
  autopilot (opt-in):
49
51
  hook on|off|status proactively suggest a skill in Claude when your prompt fits one
50
52
  gaps kinds of work you keep doing without a skill (run: skills-atlas hook on)
53
+ craft codify a workflow you keep repeating into a new local skill (Claude drafts it)
51
54
  prune installed skills you no longer use — Claude suggests removing them
55
+ feedback what the autopilot learned from your installs/removes (sharpens it)
52
56
 
53
57
  catalog:
54
58
  update refresh the catalog from the public data feed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-atlas-cli",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Search, install and learn AI agent skills from the terminal — powered by the Skills Atlas catalog.",
5
5
  "bin": {
6
6
  "skills-atlas": "bin/skills.js",
@@ -0,0 +1,53 @@
1
+ // `skills-atlas craft` — feature ②: codify the user's OWN repeated workflow into a
2
+ // new local SKILL.md. Run it in Claude Code: it hands the MAIN agent the detected
3
+ // pattern + fuller evidence + a hardened authoring instruction, and the agent drafts
4
+ // ./.claude/skills/<name>/SKILL.md for the user to review. It writes nothing itself.
5
+ 'use strict';
6
+
7
+ const { parse } = require('../args');
8
+ const transcripts = require('../transcripts');
9
+ const gapstate = require('../gapstate');
10
+ const gaps = require('./gaps');
11
+ const registry = require('../registry');
12
+ const { dim, langHint } = require('../format');
13
+
14
+ const HELP = `usage: skills-atlas craft
15
+
16
+ Codify a multi-step workflow you keep repeating into a new local skill — distilled
17
+ from your OWN steps, written by Claude for you to review. Run it inside Claude Code.
18
+ Reads your local transcripts; nothing is stored or sent. It writes nothing itself —
19
+ it hands Claude an instruction; Claude drafts ./.claude/skills/<name>/SKILL.md, and
20
+ only if your workflow carries a real user-specific delta (otherwise it declines).
21
+
22
+ craft draft a skill from the workflow the autopilot flagged (or recent activity)
23
+ --json print the detected pattern + evidence as JSON
24
+ --show alias for the default (print the instruction)`;
25
+
26
+ module.exports = async function craftCmd(argv) {
27
+ const { values } = parse(argv, ['json', 'show']);
28
+ if (values.help) { console.log(HELP); return; }
29
+
30
+ const recent = transcripts.recentPrompts({ max: 30 });
31
+ const pending = gapstate.readCraft();
32
+ const pattern = pending && pending.pattern
33
+ ? pending.pattern
34
+ : '(no pre-detected pattern — infer the user\'s repeated multi-step procedure from the evidence below; if there is no genuine user-specific delta, do NOT write a skill, per the gate.)';
35
+
36
+ if (values.json) {
37
+ console.log(JSON.stringify({
38
+ pattern: pending ? pending.pattern : null,
39
+ fromAutopilot: !!pending,
40
+ evidence: gaps.craftEvidence(recent),
41
+ }, null, 2));
42
+ return;
43
+ }
44
+
45
+ if (!recent.length) {
46
+ console.log(dim('no recent activity to craft from — use Claude Code for a while, then run this in a session.'));
47
+ return;
48
+ }
49
+
50
+ // Surfaced → consume it so the same pattern isn't offered again.
51
+ if (pending) gapstate.clearCraft();
52
+ console.log('\n' + gaps.craftInstruction(pattern, recent) + langHint(registry.getAutopilot().replyLang));
53
+ };
@@ -0,0 +1,53 @@
1
+ // `skills-atlas feedback` — the autopilot's local suppression list. Two scopes:
2
+ // skills you've dismissed (never suggested, anywhere) and skills you've removed from
3
+ // THIS project (not suggested here). Show / add to / reset it. Nothing is sent.
4
+ 'use strict';
5
+
6
+ const { parse } = require('../args');
7
+ const feedback = require('../feedback');
8
+ const { green, dim } = require('../format');
9
+
10
+ const HELP = `usage: skills-atlas feedback [dismiss <skill> | reset]
11
+
12
+ Skills the autopilot won't suggest again. Two kinds, local only — nothing is sent:
13
+ • dismissed — you said "never suggest this"; applies in every project
14
+ • removed — you removed it from THIS project; suppressed here only
15
+ Installing a skill clears both. (Removing one from the global scope is just an
16
+ uninstall — it does not suppress; use 'dismiss' for a blanket no.)
17
+
18
+ feedback show the suppression list (this project + global)
19
+ feedback dismiss <skill> never suggest a skill again, in any project
20
+ feedback reset forget everything (global + this project)
21
+ --json`;
22
+
23
+ module.exports = async function feedbackCmd(argv) {
24
+ const { values, positionals } = parse(argv, ['json']);
25
+ if (values.help) { console.log(HELP); return; }
26
+ const sub = positionals[0];
27
+
28
+ if (sub === 'reset') {
29
+ feedback.clear();
30
+ console.log(values.json ? JSON.stringify({ reset: true }) : `${green('✓')} feedback reset.`);
31
+ return;
32
+ }
33
+ if (sub === 'dismiss') {
34
+ const x = positionals.slice(1).join(' ');
35
+ if (!x) { console.error('usage: skills-atlas feedback dismiss <skill>'); process.exitCode = 1; return; }
36
+ feedback.dismiss(x);
37
+ console.log(values.json ? JSON.stringify({ dismissed: x }) : `${green('✓')} won't suggest ${x} again (any project).`);
38
+ return;
39
+ }
40
+
41
+ const cur = feedback.current();
42
+ if (values.json) {
43
+ console.log(JSON.stringify({ dismissed: [...cur.global], removedHere: [...cur.project] }, null, 2));
44
+ return;
45
+ }
46
+ if (!cur.global.size && !cur.project.size) {
47
+ console.log(dim('nothing suppressed — run `skills-atlas feedback dismiss <skill>`, or remove a\nskill from this project, and the autopilot stops offering it.'));
48
+ return;
49
+ }
50
+ if (cur.global.size) console.log(`\n${green("won't suggest anywhere")} (${cur.global.size}): ${[...cur.global].join(', ')}`);
51
+ if (cur.project.size) console.log(`${green("won't suggest in this project")} (${cur.project.size}): ${[...cur.project].join(', ')}`);
52
+ console.log(dim('re-install one to clear it, or: skills-atlas feedback reset'));
53
+ };
@@ -7,6 +7,9 @@
7
7
  'use strict';
8
8
 
9
9
  const { spawn } = require('child_process');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
10
13
  const { parse } = require('../args');
11
14
  const { loadData } = require('../data');
12
15
  const { buildIndices } = require('../index-build');
@@ -21,6 +24,17 @@ const { dim, green } = require('../format');
21
24
  const CLAUDE_BIN = process.env.SKILLS_ATLAS_CLAUDE_BIN || 'claude';
22
25
  const TIMEOUT_MS = Number(process.env.SKILLS_ATLAS_GAP_TIMEOUT_MS) || 90000;
23
26
 
27
+ // Project + global CLAUDE.md, lightly, so the detector's Gate 4 ("already covered by
28
+ // a rule?") is real — the small model can't read the filesystem itself.
29
+ function readClaudeMd() {
30
+ let out = '';
31
+ for (const f of [path.join(process.cwd(), 'CLAUDE.md'), path.join(os.homedir(), '.claude', 'CLAUDE.md')]) {
32
+ try { out += fs.readFileSync(f, 'utf8') + '\n'; } catch { /* none here */ }
33
+ if (out.length > 1200) break;
34
+ }
35
+ return out.slice(0, 1200);
36
+ }
37
+
24
38
  function gather() {
25
39
  const recent = transcripts.recentPrompts({ max: 20 });
26
40
  const gs = gapstate.read();
@@ -31,7 +45,7 @@ function gather() {
31
45
  for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installed.add(e.skill);
32
46
  const candidates = gaps.candidatePool(flatRows, recent, { installed, dismissed });
33
47
  const ap = registry.getAutopilot();
34
- return { recent, dismissed, candidates, model: ap.gapModel, lang: ap.replyLang };
48
+ return { recent, dismissed, candidates, installed: [...installed], claudeMd: readClaudeMd(), model: ap.gapModel, lang: ap.replyLang };
35
49
  }
36
50
 
37
51
  // Run `claude -p --model <model>` with the prompt on stdin. Reuses Claude Code's
@@ -76,7 +90,7 @@ module.exports = async function gapAnalyze(argv) {
76
90
  if (values.show || values.once) console.error(dim(`not enough recent activity (${g.recent.length} < 8) — nothing to analyze.`));
77
91
  return;
78
92
  }
79
- const prompt = gaps.analysisPrompt(g.recent, g.candidates, g.dismissed, g.lang);
93
+ const prompt = gaps.analysisPrompt(g.recent, g.candidates, g.dismissed, g.lang, { installed: g.installed, claudeMd: g.claudeMd });
80
94
 
81
95
  if (values.show) {
82
96
  console.log(dim(`# would run: ${CLAUDE_BIN} -p --model ${g.model} (prompt piped on stdin)\n`));
@@ -86,8 +100,22 @@ module.exports = async function gapAnalyze(argv) {
86
100
 
87
101
  const r = await runClaude(prompt, g.model);
88
102
  if (r.ok) {
89
- gapstate.writePending({ text: r.text, source: g.model });
90
- if (values.once) console.log(`${green('✓')} ${g.model} →\n${r.text}`);
103
+ if (/^CRAFT:/i.test(r.text)) {
104
+ // A craftable workflow. Suppress if we already surfaced this exact pattern
105
+ // recently (don't re-nag — the Cursor failure); otherwise stash it for `craft`.
106
+ const fp = gapstate.craftFingerprint(r.text);
107
+ if (gapstate.craftOnCooldown(fp)) {
108
+ gapstate.writePending({ text: 'NONE', source: g.model });
109
+ if (values.once) console.error(dim('CRAFT matches a pattern surfaced within ~7d — staying silent (no re-nag).'));
110
+ } else {
111
+ gapstate.writeCraft({ line: r.text, pattern: r.text.replace(/^CRAFT:\s*/i, '').split('|')[0].trim(), fp });
112
+ gapstate.writePending({ text: r.text, source: g.model });
113
+ if (values.once) console.log(`${green('✓')} ${g.model} →\n${r.text}`);
114
+ }
115
+ } else {
116
+ gapstate.writePending({ text: r.text, source: g.model });
117
+ if (values.once) console.log(`${green('✓')} ${g.model} →\n${r.text}`);
118
+ }
91
119
  } else {
92
120
  // fail-open: stash the local inline digest so the hook still surfaces SOMETHING
93
121
  // (the previous behavior, just one tick later) instead of going silent.
@@ -34,6 +34,7 @@ const INSTRUCTION = dismissed =>
34
34
  ` If nothing clearly recurs, say there are no gaps.`;
35
35
 
36
36
  const { runSearch } = require('../search-core');
37
+ const craftPrompts = require('../craft-prompts');
37
38
 
38
39
  // Lexical recall over the AGGREGATED recent activity → candidate skills the user
39
40
  // hasn't installed. The signal is stronger than any single prompt; the model
@@ -61,17 +62,30 @@ function digest(recent, dismissed, candidates = []) {
61
62
  return `[Skills Atlas — capability gaps] The user's recent requests (${recent.length} over ~${days} day(s), newest first):\n${activityLines(recent)}${candidateLines(candidates)}\n\n${INSTRUCTION(dismissed)}`;
62
63
  }
63
64
 
64
- // The prompt handed to the small BACKGROUND model. It returns NONE or one line.
65
- function analysisPrompt(recent, candidates, dismissed, lang) {
65
+ // The prompt handed to the small BACKGROUND model. Adversarially hardened (see
66
+ // craft-prompts.js). Emits exactly one of NONE / EXISTING <skill> / CRAFT: <pattern>.
67
+ // `extra.installed` (names) and `extra.claudeMd` (excerpt) make Gate 4 real at
68
+ // detection time — the small model can't see the filesystem, so we feed it in.
69
+ function analysisPrompt(recent, candidates, dismissed, lang, extra = {}) {
66
70
  const langName = lang === 'zh' ? '简体中文' : 'English';
67
- return 'You look at a developer\'s recent activity and spot ONE recurring kind of work that an installable "skill" would help with but they haven\'t set up.\n\n' +
68
- `Recent requests (newest first):\n${activityLines(recent)}\n` +
69
- (candidates.length ? `\nInstallable catalog skills that may be relevant:\n${candidates.map(c => `- ${c.skill}: ${c.use}`).join('\n')}\n` : '') +
70
- (dismissed.length ? `\nAlready dismissed (never suggest): ${dismissed.join(', ')}\n` : '') +
71
- `\nIf there is a CLEAR recurring need that ONE of the listed skills covers, reply with a single line in ${langName}:\n` +
72
- ' <skill-name> one short sentence naming the recurring pattern (with rough frequency) and why that skill helps.\n' +
73
- 'Otherwise reply with exactly: NONE\n' +
74
- 'Output ONLY that one line (or NONE), nothing else.';
71
+ return craftPrompts.detectionPrompt({
72
+ activity: activityLines(recent),
73
+ candidates: candidates.length ? candidates.map(c => `- ${c.skill}: ${c.use}`).join('\n') : '(none matched)',
74
+ dismissed: dismissed.length ? dismissed.join(', ') : '(none)',
75
+ installed: (extra.installed && extra.installed.length) ? extra.installed.join(', ') : '(none)',
76
+ claudeMd: extra.claudeMd ? String(extra.claudeMd).replace(/\s+/g, ' ').trim().slice(0, 800) : '(none found)',
77
+ lang: langName,
78
+ });
79
+ }
80
+
81
+ // Fuller per-prompt evidence for `craft` — detection truncates each prompt to ~100
82
+ // chars, but the corrections that prove the user's delta need more room (~400).
83
+ function craftEvidence(recent, { chars = 400, max = 24 } = {}) {
84
+ return recent.slice(0, max).map(r => ` - ${r.text.replace(/\s+/g, ' ').trim().slice(0, chars)}`).join('\n');
85
+ }
86
+ // The instruction injected into the MAIN agent when the user runs `craft`.
87
+ function craftInstruction(pattern, recent, opts) {
88
+ return craftPrompts.craftPrompt({ pattern, evidence: craftEvidence(recent, opts) });
75
89
  }
76
90
 
77
91
  module.exports = async function gapsCmd(argv) {
@@ -112,3 +126,5 @@ module.exports = async function gapsCmd(argv) {
112
126
  module.exports.candidatePool = candidatePool;
113
127
  module.exports.digest = digest;
114
128
  module.exports.analysisPrompt = analysisPrompt;
129
+ module.exports.craftEvidence = craftEvidence;
130
+ module.exports.craftInstruction = craftInstruction;
@@ -253,6 +253,9 @@ module.exports = async function install(argv) {
253
253
  return;
254
254
  }
255
255
 
256
+ // installing clears any prior suppression (global dismiss + this project's removal).
257
+ try { require('../feedback').installed(skill); } catch { /* ignore */ }
258
+
256
259
  if (values.json) {
257
260
  const out = {
258
261
  skill, mode: 'folder', source: src.name,
@@ -40,6 +40,11 @@ module.exports = async function remove(argv) {
40
40
  if (fsu.dirExists(dest)) fsu.rmrf(dest);
41
41
  manifest.remove(root, name);
42
42
 
43
+ // Removing a PROJECT skill is a "not in this project" signal — suppress it here only,
44
+ // never across projects (it may be useful elsewhere). Removing a GLOBAL skill is just
45
+ // uninstalling; for a blanket "never suggest this", use `feedback dismiss`.
46
+ if (!global) { try { require('../feedback').removedInProject(name); } catch { /* ignore */ } }
47
+
43
48
  if (values.json) { console.log(JSON.stringify({ removed: name, dest })); return; }
44
49
  console.log(`${green('✓')} removed ${name} ${dim(fsu.tildify(dest))}`);
45
50
  if (otherHas) console.log(dim(`note: '${name}' is still installed in the ${global ? 'project' : 'global'} scope — remove that too: skills-atlas remove ${name} ${otherFlag}`));
@@ -76,9 +76,17 @@ module.exports = async function suggest() {
76
76
  gapstate.clearPending();
77
77
  const txt = (pending.text || '').trim();
78
78
  if (txt && !/^NONE\b/i.test(txt)) {
79
- const body = pending.source === 'fallback'
80
- ? txt // already a full digest for the main agent to judge
81
- : `[Skills Atlas capability gaps] ${txt}\nOffer this to the user only if it genuinely fits — verify with \`skills-atlas info <skill>\`, install with \`skills-atlas use <skill> --yes --project\`; otherwise stay silent.`;
79
+ let body;
80
+ if (/^CRAFT:/i.test(txt)) {
81
+ // Feature ②: a craftable workflow no skill covers. Restrained, decline-easy offer.
82
+ const pat = txt.replace(/^CRAFT:\s*/i, '').split('|')[0].trim();
83
+ body = `[Skills Atlas autopilot] The user keeps doing a multi-step workflow that no installed or catalog skill covers: ${pat}. ` +
84
+ `ONLY if this genuinely fits what they're actually doing, offer once — in one easy-to-decline line — to codify it into a reusable skill built from their OWN steps: they can run \`skills-atlas craft\` and you'll draft a SKILL.md from their workflow for them to review (nothing is written until they run it; it's a draft they keep or delete). If it doesn't clearly fit, say nothing about this at all.`;
85
+ } else {
86
+ body = pending.source === 'fallback'
87
+ ? txt // already a full digest for the main agent to judge
88
+ : `[Skills Atlas — capability gaps] ${txt}\nOffer this to the user only if it genuinely fits — verify with \`skills-atlas info <skill>\`, install with \`skills-atlas use <skill> --yes --project\`; otherwise stay silent.`;
89
+ }
82
90
  emit(body + langHint(ap.replyLang));
83
91
  gapstate.touchNudge(gapstate.activitySignature(recent));
84
92
  writeState(file, state);
@@ -131,7 +139,7 @@ module.exports = async function suggest() {
131
139
  for (const s of fsu.scopesFor({})) for (const e of manifest.list(s.root)) installed.add(e.skill);
132
140
  const suggested = new Set(state.suggested || []);
133
141
 
134
- const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested });
142
+ const { fire, candidates } = suggestCandidates(flatRows, prompt, { installed, suggested, feedback: require('../feedback').current() });
135
143
  if (!fire || !candidates.length) { writeState(file, state); return; }
136
144
 
137
145
  const lines = candidates.map(c => {
@@ -146,7 +154,8 @@ module.exports = async function suggest() {
146
154
  `fits THIS task (it's a curated skill from the Skills Atlas catalog, not something you made up), ` +
147
155
  `then let them choose — activate it now (\`skills-atlas use <skill> --yes --project\` installs + applies it ` +
148
156
  `immediately), see what it covers first (\`skills-atlas info <skill>\`), or skip and you'll just ` +
149
- `do the task yourself. If none fit but the task plainly needs a specialized skill, you may run ` +
157
+ `do the task yourself; if they ask to never suggest it again, run \`skills-atlas feedback dismiss <skill>\`. ` +
158
+ `If none fit but the task plainly needs a specialized skill, you may run ` +
150
159
  `\`skills-atlas search "<short intent>"\` to look further. If nothing fits, say nothing about this ` +
151
160
  `at all — don't mention this hook, these skills, or that a suggestion was made.` + langHint(ap.replyLang));
152
161
  state.lastSuggestedCount = state.count;
@@ -0,0 +1,136 @@
1
+ // The two model-facing prompts for feature ② "craft" (generative gap-filling),
2
+ // hardened by an adversarial multi-agent pass (drafts → critique → synthesis).
3
+ //
4
+ // • DETECTION — handed to the small background model (gap-analyze). Reads the user's
5
+ // recent prompts + matched catalog skills and emits exactly one of
6
+ // NONE / EXISTING <skill> / CRAFT: <pattern> | DELTA: <quoted artifact>.
7
+ // Biased hard toward NONE; the firewall is a QUOTABLE user-specific delta.
8
+ // • CRAFT_INSTRUCTION — injected into the MAIN agent when the user runs `skills-atlas
9
+ // craft`. Distills the user's OWN repeated procedure into one SKILL.md,
10
+ // self-contained, delta-only, draft-for-review, never auto-active.
11
+ //
12
+ // Placeholders are filled by split/join (no regex, so `$` in the activity text is safe).
13
+ 'use strict';
14
+
15
+ const DETECTION = `You read a developer's recent Claude Code requests and decide whether ONE recurring kind of work is worth acting on. Your default answer is NONE, and it is the right answer most of the time. Only an unmistakable, repeated, concrete signal overrides that default. When in any doubt, output NONE. It is far better to MISS a real pattern than to NAG the user with a weak, generic, or one-off one.
16
+
17
+ Recent requests (newest first):
18
+ {{ACTIVITY}}
19
+
20
+ Installable catalog skills that lexically matched this activity (these MAY be relevant — judge for yourself; lexical matching is shallow and may miss or misfire):
21
+ {{CANDIDATES}}
22
+
23
+ Already dismissed (never suggest and never craft these, or anything equivalent to them): {{DISMISSED}}
24
+ Already installed (a need one of these already covers is NOT uncovered — never CRAFT or recommend these): {{INSTALLED}}
25
+ Your project CLAUDE.md rules (excerpt; if a rule here already governs the procedure, it is covered → not CRAFT): {{CLAUDEMD}}
26
+
27
+ WHAT YOU SEE, AND ITS LIMITS. The list above is ONLY the user's own typed prompts, each truncated to ~100 characters, with no timestamps and no replies from the assistant. You therefore CANNOT see the assistant's answers, tool calls, diffs, or most of the corrections the user actually made. Do not pretend to. A task being phrased similarly several times is just how a routine recurring task looks — it is NOT, by itself, evidence that the user does it in a special way.
28
+
29
+ You output EXACTLY ONE of three verdicts and nothing else:
30
+ - NONE — nothing clearly recurs, the signal is too thin, or the recurring work has no user-specific, codifiable procedure. This is the common case.
31
+ - EXISTING — a real recurring need that ONE of the listed catalog skills already covers; the user just hasn't installed it.
32
+ - CRAFT — a recurring multi-step PROCEDURE the user performs in their OWN particular, visible way, that NO listed catalog skill covers, worth distilling into a new local skill from their actual workflow.
33
+
34
+ === HARD PRECONDITIONS (if any fails → NONE) ===
35
+ - At least THREE separate, substantive requests in the list are clearly the SAME kind of work. Ignore filler and continuation prompts ("yes", "ok", "continue", "go on", "run it", "next", "thanks") and anything under ~5 words when counting — they are not independent requests.
36
+ - Those three must look spread across different occasions, not one continuous back-and-forth on a single task or feature. You have no timestamps, so if the three reasonably read as one focused session iterating on one thing, treat it as a single occasion → NONE.
37
+ - The evidence must be concrete in the text above. If you are inferring the pattern from one request plus imagination, or guessing what the user "probably" does off-screen, output NONE.
38
+ - If the activity is sparse, generic, or a scatter of unrelated tasks, output NONE.
39
+
40
+ === PREFER EXISTING OVER CRAFT ===
41
+ An installable catalog skill beats authoring a new one. If a listed catalog skill plausibly covers the recurring need, choose EXISTING. Only consider CRAFT when no listed skill fits.
42
+
43
+ === THE FOUR GATES FOR CRAFT — ALL must hold, or do NOT emit CRAFT ===
44
+
45
+ GATE 1 — RECURS. The same kind of work appears on 3+ distinct, substantive occasions (per the preconditions). Frequency is NECESSARY but NOT sufficient: it only proves the TOPIC recurs, never that there is anything user-specific to codify.
46
+
47
+ GATE 2 — IS A PROCEDURE. Multi-step work with an order or set of conventions — a workflow, checklist, or pipeline — NOT a single-shot task and NOT a standing one-line preference.
48
+ - A single action is not a procedure: "fix this typo", "rename X", "explain this error", "translate this", "add an index". Reject.
49
+ - A single standing preference is not a procedure: "always use tabs", "prefer named exports", "reply in Chinese", "PR titles start with the ticket id". That is a CLAUDE.md rule with no steps, NOT a skill. Reject → NONE.
50
+ Test: "If I wrote this down, would it be an ORDERED list of several steps the user repeats, or just one instruction?" Only the ordered list passes. A multi-step task is still necessary-but-far-from-sufficient — most multi-step work is generic and dies at Gate 3.
51
+
52
+ GATE 3 — CARRIES A VISIBLE, USER-SPECIFIC DELTA (the firewall; apply it ruthlessly). The procedure must encode something a competent assistant would NOT already do by default — the user's OWN conventions: a named command/script/flag, a specific file path or layout, a fixed format or checklist they impose, a non-obvious ordering constraint, or a correction they explicitly keep re-issuing ("again", "like before", "not like that — do it our way", "you forgot X", "always do X first", "use OUR script/format").
53
+ You may claim a delta ONLY if you can QUOTE the specific token(s) from the requests above that prove it — an exact command, filename, flag, path, format name, or explicit correction, appearing across the recurring requests. The delta must be QUOTABLE, not inferred from the topic.
54
+ A REPEATED INSTRUCTION IS NOT, BY ITSELF, A DELTA. Because you see only the user's prompts (never the assistant's replies), a routine task naturally gets phrased the same way each time; that sameness is not a correction and not a convention. If the recurring steps are ones any competent assistant already performs (validate → handler → test, lint → test → commit, reproduce → fix → re-run, write up → down → migrate, write tests, run the linter, use clear names, handle errors), the delta is ZERO → NONE. A vague topic ("they do a lot of testing", "a deployment workflow") is not a delta.
55
+ ANTI-CONFABULATION: You are forbidden from inventing a delta to justify CRAFT. If you cannot quote the evidence that proves a user-specific delta, the answer is NONE — even when the work clearly recurs and is multi-step. Recurrence + ordered steps WITHOUT a quotable delta = NONE. If your CRAFT line would contain only generic verbs (add / update / write / test / deploy / fix) and category nouns, output NONE. Specificity test: if the delta would survive find-replacing this project's name with any other project's, it is generic → NONE.
56
+
57
+ GATE 4 — NOT ALREADY COVERED. No listed catalog skill fits, nothing in "Already installed" covers it, and no CLAUDE.md rule above already handles it. An ABSENT candidate is NOT proof of "uncovered": lexical matching is shallow and may have missed a covering skill. For a common engineering task (testing, PR prep, releasing, migrations, adding endpoints, debugging), assume a catalog or built-in capability likely covers it and prefer EXISTING or NONE. Treat it as genuinely uncovered ONLY when the procedure is idiosyncratic to THIS user — which is exactly what Gate 3 must already have proven.
58
+
59
+ If all four gates hold AND no catalog skill fits → CRAFT. In every other case → NONE.
60
+
61
+ === OUTPUT — emit EXACTLY ONE line, nothing before or after, no preamble, no markdown, no second line. Write the line (other than the literal word NONE) in {{LANG}}. ===
62
+
63
+ NONE
64
+ — or —
65
+ EXISTING <skill-name> — one line: the recurring pattern + rough frequency (e.g. "~4 times") + why that listed catalog skill (named verbatim) covers it
66
+ — or —
67
+ CRAFT: <one line naming the uncovered recurring multi-step procedure + its rough frequency as evidence> | DELTA: <the concrete user-specific thing it would encode, quoting the exact command / path / flag / format / correction you saw in the requests above>
68
+
69
+ Rules for the line:
70
+ - EXISTING <skill-name> must be one of the listed catalog skills, verbatim.
71
+ - CRAFT names the user's ACTUAL procedure, not a topic area, and you must be able to fill DELTA with a real quoted artifact. If you cannot, fall back to NONE.
72
+ - When the four gates are not ALL clearly satisfied, output NONE.`;
73
+
74
+ const CRAFT_INSTRUCTION = `[Skills Atlas — craft a skill from your own repeated workflow]
75
+
76
+ A background detector flagged a recurring multi-step procedure in the user's recent Claude Code activity that no installed or catalog skill appears to cover, and the user has now run \`skills-atlas craft\`. Your job is to draft a local project skill that codifies THE USER'S OWN procedure — self-evolution from their actual usage. You are distilling what THEY already do; you are NOT authoring a generic best-practice guide for a topic. The detector is deliberately conservative but works from thin evidence (the user's prompts, truncated, no replies); YOU have the real project in front of you, so part of your job is to VETO it when the signal doesn't hold up.
77
+
78
+ DETECTED PATTERN:
79
+ {{PATTERN}}
80
+
81
+ RELEVANT RECENT-ACTIVITY EVIDENCE (the user's own requests that show this procedure, newest first):
82
+ {{EVIDENCE}}
83
+
84
+ === STEP 1 — EXTRACT THE DELTA, OR BAIL (do this before writing anything) ===
85
+ Find what makes THIS user's procedure non-obvious. Produce a concrete list of the user-specific artifacts: their exact commands, scripts, flags, file paths, naming conventions, ordering constraints, fixed formats/checklists, and especially any instruction they kept RE-ISSUING because the generic approach was wrong. Quote them from the evidence.
86
+
87
+ To ground this in reality rather than guessing, you MAY first read existing project files the evidence points at — ./CLAUDE.md, ~/.claude/CLAUDE.md, ./.claude/skills/*/SKILL.md, a Makefile/justfile, the test directory, an existing handler/migration the user keeps editing. Read only; write nothing except the one SKILL.md. Use these to confirm the user's ACTUAL conventions instead of inventing plausible ones.
88
+
89
+ Then apply the hard gate. Proceed to write ONLY if ALL hold:
90
+ 1. The evidence shows the same multi-step procedure on more than one distinct occasion.
91
+ 2. It is a genuine ordered procedure, not a single-shot task and not a single standing preference. If the only real signal is ONE standing rule (e.g. "regenerate the spec with \`make openapi\`, never the IDE plugin"), that belongs in CLAUDE.md, NOT a skill — say so and write nothing. Never pad one rule with generic steps to make it "skill-shaped".
92
+ 3. You can quote at least TWO specific evidence lines (or grounding files) carrying the user's particular convention/format/flag/path/correction — something beyond how any competent assistant would already do this task. If that list is empty or only generic steps, STOP.
93
+ 4. Nothing already installed (./.claude/skills/, ~/.claude/skills/), no listed catalog skill, and no CLAUDE.md rule already does this. Before choosing a name, list ./.claude/skills/* ; if a sibling already covers it, stop.
94
+
95
+ If any check fails, write NO file. Say in one line what you found and why it's not skill-worthy — e.g. "The pattern recurs but I can't see anything specific to how YOU do it — there's no delta worth a skill yet; tell me your conventions, or this may just belong in CLAUDE.md." Default to stopping: a missing file is the correct, safe outcome, and the user running \`craft\` is NOT itself evidence a delta exists. A skill that restates common knowledge is a failure, not a deliverable.
96
+
97
+ === STEP 2 — CLARIFY ONLY IF GENUINELY AMBIGUOUS ===
98
+ If the procedure is real but one key convention is unclear from the evidence and the files, ask the user ONE focused question, then write. Otherwise write directly — do not interrogate them.
99
+
100
+ === STEP 3 — WRITE THE SKILL.md ===
101
+ Write exactly ONE file: ./.claude/skills/<name>/SKILL.md (create the folder). Touch nothing else; do not run git; do not install or enable anything. You are the author — do not depend on any external skill-creator. Follow Anthropic's skill conventions exactly.
102
+
103
+ Frontmatter (YAML between --- fences, only these two keys):
104
+ - name: lowercase-with-hyphens, <=64 chars, in GERUND / action form naming the procedure (e.g. running-release-checks, preparing-pr-descriptions). It MUST match the <name> folder.
105
+ - description: third person, one or two sentences stating WHAT the skill does AND WHEN to use it, with concrete trigger terms drawn from the user's ACTUAL nouns in the evidence (their service/file/tool/format names), not the bare category word — so it auto-loads at the right moment. (e.g. "Runs the project's pre-release checklist. Use when cutting a release, tagging a version, or before publishing.")
106
+
107
+ Body (Markdown — encode ONLY the user-specific delta):
108
+ - Open with one line on when this applies.
109
+ - Then the CORE: the user's ACTUAL procedure as a clear ORDERED workflow or checklist — their real steps, in their order, with their exact commands / paths / file names / flags / formats inlined verbatim from the evidence. Where the user kept correcting or re-instructing something, make that an explicit, prominent step — those corrections are the whole point.
110
+ - If the user has a fixed output format or checklist, reproduce it as a template/code block exactly as they use it.
111
+ - Briefly note WHICH repeated requests (or which grounding file) each convention came from, so the user can confirm you did not invent it.
112
+ - End with a one-line provenance note, e.g.: "_Locally crafted by Skills Atlas, distilled from your own repeated workflow — review and edit freely; delete the folder to remove it._"
113
+
114
+ HARD CONSTRAINTS — the firewall against correct-but-useless output:
115
+ - Every numbered step MUST carry a user-specific token (a real command, path, flag, or named format). A step that is only a generic verb phrase ("write a test", "update the docs", "handle errors", "run the linter", "use clear names") must be DELETED — the assistant already does that by default. If, after deleting all generic steps, fewer than ~2 substantive delta-bearing steps remain, you do NOT have a skill — bail per Step 1.
116
+ - Challenge every token. Assume the reader is already a capable agent who knows generic best practice; write only what is specific to THIS user and THIS procedure. Keep SKILL.md tight and well under ~500 lines; most crafted skills are far shorter.
117
+ - Use progressive disclosure: push long reference material into a sibling file (one level deep) only if genuinely needed; most crafted skills are a single SKILL.md.
118
+ - Do not invent conventions the user has not shown. If unsure whether a step is theirs or generic, leave it out or mark it clearly for them to confirm.
119
+
120
+ === STEP 4 — SHOW YOUR WORK ===
121
+ Print the path you wrote and the full SKILL.md contents. Tell the user this is a DRAFT distilled from their own usage: review and edit it, it loads automatically next session, and deleting the folder discards it. In one line, state the specific user-delta you encoded (and where it came from) so they can sanity-check you captured the right thing. Do not commit it or take any further action unless they ask. Reply in the user's language.`;
122
+
123
+ const fill = (tpl, map) =>
124
+ Object.keys(map).reduce((s, k) => s.split('{{' + k + '}}').join(map[k]), tpl);
125
+
126
+ function detectionPrompt({ activity, candidates, dismissed, installed, claudeMd, lang }) {
127
+ return fill(DETECTION, {
128
+ ACTIVITY: activity, CANDIDATES: candidates, DISMISSED: dismissed,
129
+ INSTALLED: installed, CLAUDEMD: claudeMd, LANG: lang,
130
+ });
131
+ }
132
+ function craftPrompt({ pattern, evidence }) {
133
+ return fill(CRAFT_INSTRUCTION, { PATTERN: pattern, EVIDENCE: evidence });
134
+ }
135
+
136
+ module.exports = { DETECTION, CRAFT_INSTRUCTION, detectionPrompt, craftPrompt };
@@ -0,0 +1,86 @@
1
+ // Local suppression memory for the autopilot. Two scopes, because the two signals
2
+ // mean different things:
3
+ //
4
+ // • dismiss — you explicitly said "never suggest this skill" → GLOBAL table
5
+ // • removed — you removed a project-scoped skill you'd installed → PROJECT table
6
+ //
7
+ // A dismiss is a deliberate, blanket "no", so it applies everywhere. A removal is
8
+ // contextual ("done with it here") — it must NOT leak to other projects, so it lives
9
+ // in a per-project table keyed by the project root. The LATEST action per skill wins:
10
+ // installing a skill clears both its global dismiss and this project's removal.
11
+ //
12
+ // All local; project tables live under the cache (keyed by path hash), never in the
13
+ // repo, so personal suppression is never committed. Nothing is sent anywhere.
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ const MAX_EVENTS = 500;
22
+
23
+ function baseDir() {
24
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
25
+ return path.join(base, 'skills-atlas');
26
+ }
27
+ function globalFile() { return path.join(baseDir(), 'feedback.json'); }
28
+ function projectFile(root) {
29
+ const key = crypto.createHash('sha1').update(path.resolve(root || process.cwd())).digest('hex').slice(0, 16);
30
+ return path.join(baseDir(), 'projects', `${key}.json`);
31
+ }
32
+
33
+ function readStore(file) {
34
+ try { const s = JSON.parse(fs.readFileSync(file, 'utf8')); if (!Array.isArray(s.events)) s.events = []; return s; }
35
+ catch { return { events: [] }; }
36
+ }
37
+ function writeStore(file, s) {
38
+ try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(s)); } catch { /* best-effort */ }
39
+ }
40
+ function append(file, skill, signal) {
41
+ if (!skill || !signal) return;
42
+ const s = readStore(file);
43
+ s.events.push({ skill, signal, at: new Date().toISOString() });
44
+ if (s.events.length > MAX_EVENTS) s.events = s.events.slice(-MAX_EVENTS);
45
+ writeStore(file, s);
46
+ }
47
+
48
+ // Pure: skills whose MOST RECENT action in this store is the suppressing signal.
49
+ // (An 'accepted' afterwards clears it — you installed it, so you changed your mind.)
50
+ function suppressedFrom(events, suppressSignal) {
51
+ const latest = new Map();
52
+ for (const e of events || []) {
53
+ if (!e || !e.skill) continue;
54
+ const prev = latest.get(e.skill);
55
+ if (!prev || Date.parse(e.at) >= Date.parse(prev.at)) latest.set(e.skill, e);
56
+ }
57
+ const out = new Set();
58
+ for (const [skill, e] of latest) if (e.signal === suppressSignal) out.add(skill);
59
+ return out;
60
+ }
61
+
62
+ // --- writes ---
63
+ function dismiss(skill) { append(globalFile(), skill, 'dismissed'); } // explicit "never, anywhere"
64
+ function removedInProject(skill, root) { append(projectFile(root), skill, 'removed'); } // "not in this project"
65
+ function installed(skill, root) { append(globalFile(), skill, 'accepted'); append(projectFile(root), skill, 'accepted'); }
66
+
67
+ // --- reads ---
68
+ function globalDismissed() { return suppressedFrom(readStore(globalFile()).events, 'dismissed'); }
69
+ function projectRemoved(root) { return suppressedFrom(readStore(projectFile(root)).events, 'removed'); }
70
+
71
+ // Merged view for the autopilot hook, which runs in a project cwd: a skill is hidden
72
+ // if it's globally dismissed OR removed in this project.
73
+ function current(root) {
74
+ const global = globalDismissed();
75
+ const project = projectRemoved(root);
76
+ const suppressed = new Set([...global, ...project]);
77
+ return { suppressed, isSuppressed: s => suppressed.has(s), global, project };
78
+ }
79
+
80
+ function clear(root) { writeStore(globalFile(), { events: [] }); writeStore(projectFile(root), { events: [] }); }
81
+
82
+ module.exports = {
83
+ globalFile, projectFile, readStore, writeStore,
84
+ dismiss, removedInProject, installed,
85
+ globalDismissed, projectRemoved, current, clear,
86
+ };
package/src/gapstate.js CHANGED
@@ -13,6 +13,7 @@ const { tokenize } = require('./search-core');
13
13
  // user on their NEXT task), plus a long fallback so a persistent gap can resurface.
14
14
  const MIN_INTERVAL_MS = 90 * 60 * 1000; // ~90 min: never two nudges in a burst
15
15
  const REFRESH_INTERVAL_MS = 12 * 60 * 60 * 1000; // ~12 h: an unchanged gap may resurface
16
+ const CRAFT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // ~7 d: never re-surface the SAME craft pattern
16
17
 
17
18
  function file() {
18
19
  const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
@@ -35,6 +36,28 @@ function writePending(p) { const s = read(); s.pending = { ...p, at: new Date().
35
36
  function clearPending() { const s = read(); delete s.pending; write(s); }
36
37
  function markAnalyze() { const s = read(); s.analyzeAt = new Date().toISOString(); write(s); }
37
38
 
39
+ // --- CRAFT (feature ②): a detected, craftable workflow the user can codify. ---
40
+ // Fingerprint the pattern (not the DELTA tail) so the same craft isn't re-nagged.
41
+ function craftFingerprint(line) {
42
+ const core = String(line || '').replace(/^CRAFT:\s*/i, '').split('|')[0];
43
+ return tokenize(core.toLowerCase()).sort().slice(0, 12).join(' ');
44
+ }
45
+ // Was this exact craft pattern surfaced within the cooldown? (the Cursor-failure guard)
46
+ function craftOnCooldown(fp, now = Date.now()) {
47
+ const lc = read().lastCraft;
48
+ return !!(lc && lc.fp === fp && now - Date.parse(lc.at) < CRAFT_COOLDOWN_MS);
49
+ }
50
+ // Stash the craftable pattern for the `craft` command, and remember we surfaced it.
51
+ function writeCraft({ line, pattern, fp }) {
52
+ const s = read();
53
+ const at = new Date().toISOString();
54
+ s.craft = { line, pattern, at };
55
+ s.lastCraft = { fp, at };
56
+ write(s);
57
+ }
58
+ function readCraft() { return read().craft || null; }
59
+ function clearCraft() { const s = read(); delete s.craft; write(s); }
60
+
38
61
  // A coarse fingerprint of recent work: the most frequent contentful tokens across
39
62
  // recent prompts. When this set shifts, the user has moved to a new kind of work.
40
63
  function activitySignature(prompts, topN = 8) {
@@ -72,6 +95,7 @@ function shouldNudge(state, recent, now) {
72
95
  module.exports = {
73
96
  file, read, write, dismiss, isDismissed, touchNudge, clear,
74
97
  writePending, clearPending, markAnalyze,
98
+ craftFingerprint, craftOnCooldown, writeCraft, readCraft, clearCraft,
75
99
  activitySignature, signatureShifted, shouldNudge,
76
- MIN_INTERVAL_MS, REFRESH_INTERVAL_MS,
100
+ MIN_INTERVAL_MS, REFRESH_INTERVAL_MS, CRAFT_COOLDOWN_MS,
77
101
  };
@@ -270,10 +270,11 @@ function contentDf(rows) {
270
270
  // `fire` is true only when there's a distinctive anchor (a rare name word, or ≥2
271
271
  // matched name words) or an otherwise strong (non-weak) match — so the hook stays
272
272
  // quiet on greetings and generic dev actions ("fix the typo", "build the app").
273
- function suggestCandidates(rows, prompt, { installed = new Set(), suggested = new Set(), limit = 5 } = {}) {
273
+ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = new Set(), feedback = null, limit = 5 } = {}) {
274
274
  const tokens = tokenize(lc(prompt));
275
275
  if (tokens.length < 2) return { fire: false, candidates: [], weak: false };
276
- const taken = new Set([...installed, ...suggested]);
276
+ // `taken` also drops what you've dismissed or installed-then-removed — never re-suggest those.
277
+ const taken = new Set([...installed, ...suggested, ...(feedback ? feedback.suppressed : [])]);
277
278
  const info = segmentDf(rows);
278
279
 
279
280
  // 1. name anchors — ONLY contentful (non-generic) name words count, weighted by
@@ -365,6 +366,9 @@ function suggestCandidates(rows, prompt, { installed = new Set(), suggested = ne
365
366
  anchors.some(a => a.strong >= 2 || a.specific >= 1) ||
366
367
  contentAnchors.some(contentQualifies)
367
368
  );
369
+ // NB: feedback only SUPPRESSES (via `taken` above) — that changes which skills make
370
+ // the shortlist. We deliberately don't reorder the final ≤5: Claude reads all of them
371
+ // and picks by fit, so reordering them would do nothing.
368
372
  return { fire, candidates: out.slice(0, limit), weak };
369
373
  }
370
374