metame-cli 1.4.15 → 1.4.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.4.15",
3
+ "version": "1.4.17",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -17,6 +17,7 @@ function createAdminCommandHandler(deps) {
17
17
  getAllTasks,
18
18
  dispatchTask,
19
19
  log,
20
+ skillEvolution,
20
21
  } = deps;
21
22
 
22
23
  async function handleAdminCommand(ctx) {
@@ -40,6 +41,96 @@ function createAdminCommandHandler(deps) {
40
41
  return { handled: true, config };
41
42
  }
42
43
 
44
+ // /skill-evo — inspect and resolve skill evolution queue
45
+ if (text === '/skill-evo' || text.startsWith('/skill-evo ')) {
46
+ if (!skillEvolution) {
47
+ await bot.sendMessage(chatId, '❌ skill-evolution 模块不可用');
48
+ return { handled: true, config };
49
+ }
50
+
51
+ const arg = text.slice('/skill-evo'.length).trim();
52
+ const renderItem = (i) => {
53
+ const id = i.id || '-';
54
+ const target = i.skill_name ? `skill=${i.skill_name}` : (i.search_hint ? `hint=${i.search_hint}` : 'global');
55
+ const seen = i.last_seen || i.detected || '-';
56
+ const ev = i.evidence_count || 1;
57
+ return `- [${id}] ${i.type}/${i.status} (${target}, ev=${ev})\n ${i.reason || '(no reason)'}\n last: ${seen}`;
58
+ };
59
+
60
+ if (!arg || arg === 'list') {
61
+ const pendingAll = skillEvolution.listQueueItems({ status: 'pending', limit: 200 });
62
+ const notifiedAll = skillEvolution.listQueueItems({ status: 'notified', limit: 200 });
63
+ const installedAll = skillEvolution.listQueueItems({ status: 'installed', limit: 200 });
64
+ const dismissedAll = skillEvolution.listQueueItems({ status: 'dismissed', limit: 200 });
65
+
66
+ const pending = pendingAll.slice(0, 10);
67
+ const notified = notifiedAll.slice(0, 10);
68
+ const resolved = [...installedAll, ...dismissedAll]
69
+ .sort((a, b) => new Date(b.last_seen || b.detected || 0).getTime() - new Date(a.last_seen || a.detected || 0).getTime())
70
+ .slice(0, 5);
71
+
72
+ const lines = ['🧬 Skill Evolution Queue'];
73
+ lines.push(`pending: ${pendingAll.length} | notified: ${notifiedAll.length} | resolved(total): ${installedAll.length + dismissedAll.length}`);
74
+ if (pending.length > 0) {
75
+ lines.push('\nPending:');
76
+ for (const item of pending.slice(0, 5)) lines.push(renderItem(item));
77
+ }
78
+ if (notified.length > 0) {
79
+ lines.push('\nNotified:');
80
+ for (const item of notified.slice(0, 5)) lines.push(renderItem(item));
81
+ }
82
+ if (resolved.length > 0) {
83
+ lines.push('\nResolved (latest):');
84
+ for (const item of resolved) lines.push(renderItem(item));
85
+ }
86
+ if (pending.length === 0 && notified.length === 0 && resolved.length === 0) {
87
+ lines.push('\n(queue empty)');
88
+ }
89
+ lines.push('\n用法: /skill-evo done <id> | /skill-evo dismiss <id>');
90
+
91
+ await bot.sendMessage(chatId, lines.join('\n'));
92
+
93
+ if (bot.sendButtons) {
94
+ const actionable = [...notified, ...pending].slice(0, 3);
95
+ if (actionable.length > 0) {
96
+ const buttons = [];
97
+ for (const item of actionable) {
98
+ const label = `${item.type}:${(item.skill_name || item.search_hint || 'item').slice(0, 10)}`;
99
+ buttons.push([
100
+ { text: `✅ ${label}`, callback_data: `/skill-evo done ${item.id}` },
101
+ { text: `🙈 ${label}`, callback_data: `/skill-evo dismiss ${item.id}` },
102
+ ]);
103
+ }
104
+ await bot.sendButtons(chatId, '处理建议项:', buttons);
105
+ }
106
+ }
107
+ return { handled: true, config };
108
+ }
109
+
110
+ const doneMatch = arg.match(/^(?:done|install|installed)\s+(\S+)$/i);
111
+ if (doneMatch) {
112
+ const id = doneMatch[1];
113
+ const ok = skillEvolution.resolveQueueItemById
114
+ ? skillEvolution.resolveQueueItemById(id, 'installed')
115
+ : false;
116
+ await bot.sendMessage(chatId, ok ? `✅ 已标记 installed: ${id}` : `❌ 未找到可处理项: ${id}`);
117
+ return { handled: true, config };
118
+ }
119
+
120
+ const dismissMatch = arg.match(/^(?:dismiss|skip|ignored?)\s+(\S+)$/i);
121
+ if (dismissMatch) {
122
+ const id = dismissMatch[1];
123
+ const ok = skillEvolution.resolveQueueItemById
124
+ ? skillEvolution.resolveQueueItemById(id, 'dismissed')
125
+ : false;
126
+ await bot.sendMessage(chatId, ok ? `✅ 已标记 dismissed: ${id}` : `❌ 未找到可处理项: ${id}`);
127
+ return { handled: true, config };
128
+ }
129
+
130
+ await bot.sendMessage(chatId, '用法: /skill-evo list | /skill-evo done <id> | /skill-evo dismiss <id>');
131
+ return { handled: true, config };
132
+ }
133
+
43
134
  if (text === '/tasks') {
44
135
  const { general, project } = getAllTasks(config);
45
136
  let msg = '';
@@ -42,13 +42,6 @@ function createClaudeEngine(deps) {
42
42
  const SESSION_CWD_VALIDATION_TTL_MS = 30 * 1000;
43
43
  const _sessionCwdValidationCache = new Map(); // key: `${sessionId}@@${cwd}` -> { inCwd, ts }
44
44
 
45
- function decodeProjectDirName(dirName) {
46
- const raw = String(dirName || '');
47
- if (!raw) return '';
48
- if (raw.startsWith('-')) return '/' + raw.slice(1).replace(/-/g, '/');
49
- return raw.replace(/-/g, '/');
50
- }
51
-
52
45
  function cacheSessionCwdValidation(cacheKey, inCwd) {
53
46
  _sessionCwdValidationCache.set(cacheKey, { inCwd: !!inCwd, ts: Date.now() });
54
47
  if (_sessionCwdValidationCache.size > 512) {
@@ -84,14 +77,23 @@ function createClaudeEngine(deps) {
84
77
  if (entry && entry.projectPath) {
85
78
  return cacheSessionCwdValidation(cacheKey, normalizeCwd(entry.projectPath) === normCwd);
86
79
  }
80
+ // sessions-index may lag behind new sessions; use project-level path from any entry.
81
+ const anyProjectPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
82
+ if (anyProjectPath) {
83
+ return cacheSessionCwdValidation(cacheKey, normalizeCwd(anyProjectPath) === normCwd);
84
+ }
87
85
  }
88
86
 
89
- // Fallback: infer from encoded Claude project folder name.
90
- const inferredPath = decodeProjectDirName(path.basename(projectDir));
91
- if (inferredPath) {
92
- return cacheSessionCwdValidation(cacheKey, normalizeCwd(inferredPath) === normCwd);
87
+ // Weak fallback: encode normCwd using Claude's folder convention and accept
88
+ // only positive match. If it doesn't match, keep current session to avoid
89
+ // false mismatches for paths with non-ASCII/special characters.
90
+ const expectedDirName = '-' + normCwd.replace(/^\//, '').replace(/[\/_ ]/g, '-');
91
+ const actualDirName = path.basename(projectDir);
92
+ if (actualDirName === expectedDirName) {
93
+ return cacheSessionCwdValidation(cacheKey, true);
93
94
  }
94
- return cacheSessionCwdValidation(cacheKey, false);
95
+ // Unable to prove mismatch safely.
96
+ return cacheSessionCwdValidation(cacheKey, true);
95
97
  }
96
98
 
97
99
  // Ultimate fallback (legacy path): scoped scan in target cwd.
@@ -339,6 +339,7 @@ function createCommandRouter(deps) {
339
339
  '',
340
340
  `⚙️ /model [${currentModel}] /provider [${currentProvider}] /status /tasks /run /budget /reload`,
341
341
  '🧠 /memory — 记忆统计 · /memory <关键词> — 搜索事实',
342
+ '🧬 /skill-evo — 查看/处理技能演化队列',
342
343
  `🔧 /doctor /fix /reset /sh <cmd> /nosleep [${getNoSleepProcess() ? 'ON' : 'OFF'}]`,
343
344
  '',
344
345
  '直接打字即可对话 💬',
@@ -509,6 +509,7 @@ function createTaskScheduler(deps) {
509
509
  const notifications = skillEvolution.checkEvolutionQueue();
510
510
  for (const item of notifications) {
511
511
  let msg = '';
512
+ const idHint = item.id ? `\nID: \`${item.id}\`` : '';
512
513
  if (item.type === 'skill_gap') {
513
514
  msg = `🧬 *技能缺口检测*\n${item.reason}`;
514
515
  if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
@@ -517,6 +518,8 @@ function createTaskScheduler(deps) {
517
518
  } else if (item.type === 'user_complaint') {
518
519
  msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
519
520
  }
521
+ if (msg && item.id) msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
522
+ else if (msg) msg += idHint;
520
523
  if (msg && notifyFn) notifyFn(msg);
521
524
  }
522
525
  } catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
package/scripts/daemon.js CHANGED
@@ -52,6 +52,8 @@ try { skillEvolution = require('./skill-evolution'); } catch { /* graceful fallb
52
52
  const SKILL_ROUTES = [
53
53
  { name: 'macos-mail-calendar', pattern: /邮件|邮箱|收件箱|日历|日程|会议|schedule|email|mail|calendar|unread|inbox/i },
54
54
  { name: 'heartbeat-task-manager', pattern: /提醒|remind|闹钟|定时|每[天周月]/i },
55
+ { name: 'skill-manager', pattern: /找技能|管理技能|更新技能|安装技能|skill manager|skill scout|(?:find|look for)\s+skills?/i },
56
+ { name: 'skill-evolution-manager', pattern: /\/evolve\b|复盘一下|记录一下(这个)?经验|保存到\s*skill|skill evolution/i },
55
57
  ];
56
58
 
57
59
  function routeSkill(prompt) {
@@ -1031,6 +1033,7 @@ const { handleAdminCommand } = createAdminCommandHandler({
1031
1033
  getAllTasks,
1032
1034
  dispatchTask,
1033
1035
  log,
1036
+ skillEvolution,
1034
1037
  });
1035
1038
 
1036
1039
  const { handleSessionCommand } = createSessionCommandHandler({
@@ -11,7 +11,7 @@
11
11
  * - Writes to evolution_queue.yaml for immediate action
12
12
  *
13
13
  * COLD PATH (batched, Haiku-powered):
14
- * - Piggybacks on distill.js (every 4h or on launch)
14
+ * - Runs as standalone heartbeat script task (skill-evolve, default 6h)
15
15
  * - Haiku analyzes accumulated skill signals for nuanced insights
16
16
  * - Merges evolution data into skill's evolution.json + SKILL.md
17
17
  *
@@ -174,7 +174,8 @@ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
174
174
 
175
175
  const hasSkills = skills.length > 0;
176
176
  const hasError = !!error;
177
- const hasToolFailure = output && /(?:failed|error|not found|not available|skill.{0,20}(?:missing|absent|not.{0,10}install))/i.test(output);
177
+ const outputText = typeof output === 'string' ? output : '';
178
+ const hasToolFailure = /(?:failed|error|not found|not available|skill.{0,20}(?:missing|absent|not.{0,10}install))/i.test(outputText);
178
179
 
179
180
  // Skip if no skill involvement and no failure
180
181
  if (!hasSkills && !hasError && !hasToolFailure) return null;
@@ -185,10 +186,11 @@ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
185
186
  skills_invoked: skills,
186
187
  tools_used: tools.slice(0, 20), // cap for storage
187
188
  error: error ? error.substring(0, 500) : null,
189
+ output_excerpt: outputText.substring(0, 500),
188
190
  has_tool_failure: !!hasToolFailure,
189
191
  files_modified: (files || []).slice(0, 10),
190
192
  cwd: cwd || null,
191
- outcome: error ? 'error' : (output ? 'success' : 'empty'),
193
+ outcome: (hasError || hasToolFailure) ? 'error' : (outputText ? 'success' : 'empty'),
192
194
  };
193
195
  }
194
196
 
@@ -275,7 +277,12 @@ function checkHotEvolution(signal) {
275
277
 
276
278
  // Rule 3: Missing skill detection
277
279
  const missingRe = new RegExp(policy.missing_skill_patterns.join('|'), 'i');
278
- if (signal.outcome !== 'success' && signal.prompt && missingRe.test((signal.error || '') + (signal.prompt || ''))) {
280
+ const missText = [(signal.error || ''), (signal.prompt || ''), (signal.output_excerpt || '')].join('\n');
281
+ const hasMissingPattern = missingRe.test(missText);
282
+ const hasExplicitSkillMiss = signal.has_tool_failure &&
283
+ /skill|技能|能力/i.test(missText) &&
284
+ /not found|missing|absent|no skill|找不到|没有找到|能力不足/i.test(missText);
285
+ if (signal.prompt && (hasMissingPattern || hasExplicitSkillMiss)) {
279
286
  addToQueue(queue, {
280
287
  type: 'skill_gap',
281
288
  skill_name: null,
@@ -295,7 +302,7 @@ function checkHotEvolution(signal) {
295
302
  if (isSuccess || isFail) {
296
303
  for (const sk of signal.skills_invoked) {
297
304
  const skillDir = findSkillDir(sk);
298
- if (skillDir) trackInsightOutcome(skillDir, isSuccess);
305
+ if (skillDir) trackInsightOutcome(skillDir, isSuccess, signal);
299
306
  }
300
307
  }
301
308
  }
@@ -305,7 +312,51 @@ function checkHotEvolution(signal) {
305
312
  * Update insight outcome stats in evolution.json for a skill.
306
313
  * Tracks success_count, fail_count, last_applied_at per insight text.
307
314
  */
308
- function trackInsightOutcome(skillDir, isSuccess) {
315
+ const INSIGHT_STOPWORDS = new Set([
316
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'where',
317
+ 'user', 'skill', 'meta', 'metame', 'should', 'always', 'never', 'please',
318
+ '问题', '用户', '技能', '需要', '应该', '这个', '那个', '以及', '如果', '然后',
319
+ ]);
320
+
321
+ function extractInsightTokens(text) {
322
+ const raw = String(text || '').toLowerCase();
323
+ const en = raw.match(/[a-z][a-z0-9_.-]{2,}/g) || [];
324
+ const zh = raw.match(/[\u4e00-\u9fff]{2,}/g) || [];
325
+ const tokens = [...en, ...zh]
326
+ .map(t => t.trim())
327
+ .filter(t => t.length >= 2 && !INSIGHT_STOPWORDS.has(t));
328
+ return [...new Set(tokens)];
329
+ }
330
+
331
+ function pickMatchedInsights(allInsights, signal) {
332
+ if (!signal || !Array.isArray(allInsights) || allInsights.length === 0) return [];
333
+
334
+ const context = [
335
+ signal.prompt || '',
336
+ signal.error || '',
337
+ signal.output_excerpt || '',
338
+ ...(Array.isArray(signal.tools_used) ? signal.tools_used.map(t => `${t.name || ''} ${t.context || ''}`) : []),
339
+ ...(Array.isArray(signal.files_modified) ? signal.files_modified : []),
340
+ ].join('\n').toLowerCase();
341
+
342
+ const scored = allInsights
343
+ .map((insight) => {
344
+ const tokens = extractInsightTokens(insight).slice(0, 12);
345
+ if (tokens.length === 0) return { insight, score: 0 };
346
+ let score = 0;
347
+ for (const token of tokens) {
348
+ if (context.includes(token)) score++;
349
+ }
350
+ return { insight, score };
351
+ })
352
+ .filter(x => x.score > 0)
353
+ .sort((a, b) => b.score - a.score);
354
+
355
+ if (scored.length > 0) return scored.slice(0, 3).map(x => x.insight);
356
+ return allInsights.length === 1 ? [allInsights[0]] : [];
357
+ }
358
+
359
+ function trackInsightOutcome(skillDir, isSuccess, signal = null) {
309
360
  const evoPath = path.join(skillDir, 'evolution.json');
310
361
  let data = {};
311
362
  try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
@@ -317,8 +368,9 @@ function trackInsightOutcome(skillDir, isSuccess) {
317
368
  ...(data.fixes || []),
318
369
  ...(data.contexts || []),
319
370
  ];
371
+ const targetInsights = signal ? pickMatchedInsights(allInsights, signal) : allInsights;
320
372
 
321
- for (const insight of allInsights) {
373
+ for (const insight of targetInsights) {
322
374
  if (!data.insights_stats[insight]) {
323
375
  data.insights_stats[insight] = { success_count: 0, fail_count: 0, last_applied_at: null };
324
376
  }
@@ -333,7 +385,7 @@ function trackInsightOutcome(skillDir, isSuccess) {
333
385
 
334
386
  // ─────────────────────────────────────────────
335
387
  // Cold Path: Haiku-Powered Analysis
336
- // (called from distill.js)
388
+ // (called by heartbeat script task: skill-evolve)
337
389
  // ─────────────────────────────────────────────
338
390
 
339
391
  /**
@@ -591,7 +643,16 @@ function loadEvolutionQueue(yaml) {
591
643
  if (!fs.existsSync(EVOLUTION_QUEUE_FILE)) return { items: [] };
592
644
  const content = fs.readFileSync(EVOLUTION_QUEUE_FILE, 'utf8');
593
645
  const data = yaml.load(content);
594
- return data && Array.isArray(data.items) ? data : { items: [] };
646
+ const queue = data && Array.isArray(data.items) ? data : { items: [] };
647
+ let changed = false;
648
+ for (const item of queue.items) {
649
+ if (!item.id) {
650
+ item.id = `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
651
+ changed = true;
652
+ }
653
+ }
654
+ if (changed) saveEvolutionQueue(yaml, queue);
655
+ return queue;
595
656
  } catch {
596
657
  return { items: [] };
597
658
  }
@@ -604,18 +665,24 @@ function saveEvolutionQueue(yaml, queue) {
604
665
  }
605
666
 
606
667
  function addToQueue(queue, entry) {
607
- // Dedup by type + skill_name (update existing instead of adding duplicate)
668
+ // Dedup pending entries by core key. Skill gaps also include search_hint so unrelated gaps don't collapse.
608
669
  const existing = queue.items.find(i =>
609
- i.type === entry.type && i.skill_name === entry.skill_name && i.status === 'pending'
670
+ i.type === entry.type &&
671
+ i.skill_name === entry.skill_name &&
672
+ i.status === 'pending' &&
673
+ (entry.type !== 'skill_gap' || (i.search_hint || '') === (entry.search_hint || ''))
610
674
  );
611
675
 
612
676
  if (existing) {
613
677
  existing.evidence_count = (existing.evidence_count || 0) + (entry.evidence_count || 1);
614
678
  existing.last_seen = new Date().toISOString();
679
+ if (entry.reason) existing.reason = entry.reason;
680
+ if (entry.search_hint) existing.search_hint = entry.search_hint;
615
681
  return;
616
682
  }
617
683
 
618
684
  queue.items.push({
685
+ id: `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
619
686
  ...entry,
620
687
  detected: new Date().toISOString(),
621
688
  last_seen: new Date().toISOString(),
@@ -639,13 +706,11 @@ function checkEvolutionQueue() {
639
706
 
640
707
  const queue = loadEvolutionQueue(yaml);
641
708
  const pendingItems = queue.items.filter(i => i.status === 'pending');
642
- if (pendingItems.length === 0) return [];
643
-
644
709
  const notifications = [];
710
+ const policy = loadPolicy();
645
711
 
646
712
  for (const item of pendingItems) {
647
713
  // Require minimum evidence before notifying
648
- const policy = loadPolicy();
649
714
  const minEvidence = item.type === 'skill_gap' ? policy.min_evidence_for_gap : policy.min_evidence_for_update;
650
715
  if ((item.evidence_count || 1) < minEvidence) continue;
651
716
 
@@ -655,13 +720,14 @@ function checkEvolutionQueue() {
655
720
  }
656
721
 
657
722
  // Prune old resolved items (> 30 days)
723
+ const beforeLen = queue.items.length;
658
724
  const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
659
725
  queue.items = queue.items.filter(i =>
660
726
  i.status === 'pending' || i.status === 'notified' ||
661
727
  (new Date(i.last_seen || i.detected).getTime() > cutoff)
662
728
  );
663
729
 
664
- if (notifications.length > 0) {
730
+ if (notifications.length > 0 || queue.items.length !== beforeLen) {
665
731
  saveEvolutionQueue(yaml, queue);
666
732
  }
667
733
 
@@ -687,6 +753,42 @@ function resolveQueueItem(type, skillName, resolution) {
687
753
  }
688
754
  }
689
755
 
756
+ /**
757
+ * Mark queue item resolved by queue id.
758
+ * Returns true when updated.
759
+ */
760
+ function resolveQueueItemById(id, resolution) {
761
+ let yaml;
762
+ try { yaml = require('js-yaml'); } catch { return false; }
763
+ if (!id) return false;
764
+
765
+ const queue = loadEvolutionQueue(yaml);
766
+ const item = queue.items.find(i =>
767
+ i.id === id && (i.status === 'pending' || i.status === 'notified')
768
+ );
769
+ if (!item) return false;
770
+
771
+ item.status = resolution; // 'installed' | 'dismissed'
772
+ item.resolved_at = new Date().toISOString();
773
+ saveEvolutionQueue(yaml, queue);
774
+ return true;
775
+ }
776
+
777
+ /**
778
+ * List queue items for manual triage.
779
+ */
780
+ function listQueueItems({ status = null, limit = 20 } = {}) {
781
+ let yaml;
782
+ try { yaml = require('js-yaml'); } catch { return []; }
783
+ const queue = loadEvolutionQueue(yaml);
784
+ const items = Array.isArray(queue.items) ? queue.items : [];
785
+ const filtered = status ? items.filter(i => i.status === status) : items;
786
+ return filtered
787
+ .slice()
788
+ .sort((a, b) => new Date(b.last_seen || b.detected || 0).getTime() - new Date(a.last_seen || a.detected || 0).getTime())
789
+ .slice(0, Math.max(1, limit));
790
+ }
791
+
690
792
  // ─────────────────────────────────────────────
691
793
  // Evolution.json Merge (JS port of merge_evolution.py)
692
794
  // ─────────────────────────────────────────────
@@ -726,8 +828,11 @@ function smartStitch(skillDir) {
726
828
  try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
727
829
 
728
830
  // Build evolution section
831
+ const AUTO_START = '<!-- METAME-EVOLUTION:START -->';
832
+ const AUTO_END = '<!-- METAME-EVOLUTION:END -->';
729
833
  const sections = [];
730
- sections.push('\n\n## User-Learned Best Practices & Constraints');
834
+ sections.push(`\n\n${AUTO_START}`);
835
+ sections.push('\n## User-Learned Best Practices & Constraints');
731
836
  sections.push('\n> **Auto-Generated Section**: Maintained by skill-evolution-manager. Do not edit manually.');
732
837
 
733
838
  // Helper: get quality indicator for an insight based on stats
@@ -755,13 +860,17 @@ function smartStitch(skillDir) {
755
860
  sections.push(`\n${data.custom_prompts}`);
756
861
  }
757
862
 
863
+ sections.push(`\n${AUTO_END}\n`);
758
864
  const evolutionBlock = sections.join('\n');
759
865
 
760
866
  let content = fs.readFileSync(skillMdPath, 'utf8');
761
- const pattern = /(\n+## User-Learned Best Practices & Constraints[\s\S]*$)/;
867
+ const markerPattern = new RegExp(`${AUTO_START}[\\s\\S]*?${AUTO_END}\\n?`);
868
+ const legacyPattern = /\n+## User-Learned Best Practices & Constraints[\s\S]*?(?=\n##\s+|\n#\s+|$)/;
762
869
 
763
- if (pattern.test(content)) {
764
- content = content.replace(pattern, evolutionBlock);
870
+ if (markerPattern.test(content)) {
871
+ content = content.replace(markerPattern, evolutionBlock.trimStart());
872
+ } else if (legacyPattern.test(content)) {
873
+ content = content.replace(legacyPattern, evolutionBlock);
765
874
  } else {
766
875
  content = content + evolutionBlock;
767
876
  }
@@ -827,6 +936,8 @@ module.exports = {
827
936
  distillSkills,
828
937
  checkEvolutionQueue,
829
938
  resolveQueueItem,
939
+ resolveQueueItemById,
940
+ listQueueItems,
830
941
  mergeEvolution,
831
942
  smartStitch,
832
943
  trackInsightOutcome,
@@ -0,0 +1,107 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+ const yaml = require('js-yaml');
8
+
9
+ const ROOT = path.resolve(__dirname, '..');
10
+
11
+ function mkHome() {
12
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'metame-skill-evo-'));
13
+ }
14
+
15
+ function runWithHome(home, code) {
16
+ return execFileSync(process.execPath, ['-e', code], {
17
+ cwd: ROOT,
18
+ env: { ...process.env, HOME: home },
19
+ encoding: 'utf8',
20
+ });
21
+ }
22
+
23
+ test('captures missing-skill failures into skill_gap queue', () => {
24
+ const home = mkHome();
25
+ runWithHome(home, `
26
+ const se = require('./scripts/skill-evolution');
27
+ const s = se.extractSkillSignal('帮我做封面图', 'Error: skill not found: nano-banana', null, [], process.cwd(), []);
28
+ se.appendSkillSignal(s);
29
+ se.checkHotEvolution(s);
30
+ `);
31
+
32
+ const queuePath = path.join(home, '.metame', 'evolution_queue.yaml');
33
+ const queue = yaml.load(fs.readFileSync(queuePath, 'utf8'));
34
+ assert.equal(Array.isArray(queue.items), true);
35
+ assert.equal(queue.items.length, 1);
36
+ assert.equal(queue.items[0].type, 'skill_gap');
37
+ assert.equal(queue.items[0].status, 'pending');
38
+ assert.ok(queue.items[0].id);
39
+ });
40
+
41
+ test('resolves queue item by id', () => {
42
+ const home = mkHome();
43
+ runWithHome(home, `
44
+ const se = require('./scripts/skill-evolution');
45
+ const s = se.extractSkillSignal('帮我做封面图', 'Error: skill not found: nano-banana', null, [], process.cwd(), []);
46
+ se.appendSkillSignal(s);
47
+ se.checkHotEvolution(s);
48
+ const items = se.listQueueItems({ status: 'pending', limit: 5 });
49
+ if (!items[0]) throw new Error('no pending queue item');
50
+ const ok = se.resolveQueueItemById(items[0].id, 'installed');
51
+ if (!ok) throw new Error('resolveQueueItemById returned false');
52
+ `);
53
+
54
+ const queuePath = path.join(home, '.metame', 'evolution_queue.yaml');
55
+ const queue = yaml.load(fs.readFileSync(queuePath, 'utf8'));
56
+ assert.equal(queue.items.length, 1);
57
+ assert.equal(queue.items[0].status, 'installed');
58
+ });
59
+
60
+ test('smartStitch preserves sections after evolution block', () => {
61
+ const home = mkHome();
62
+ runWithHome(home, `
63
+ const fs = require('fs');
64
+ const path = require('path');
65
+ const se = require('./scripts/skill-evolution');
66
+ const dir = path.join(process.env.HOME, 'sample-skill');
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), '# Demo\\n\\nBody\\n\\n## User-Learned Best Practices & Constraints\\nOld\\n\\n## KeepMe\\nKeep this section\\n');
69
+ fs.writeFileSync(path.join(dir, 'evolution.json'), JSON.stringify({ preferences: ['prefer concise output'] }, null, 2));
70
+ se.smartStitch(dir);
71
+ `);
72
+
73
+ const content = fs.readFileSync(path.join(home, 'sample-skill', 'SKILL.md'), 'utf8');
74
+ assert.match(content, /METAME-EVOLUTION:START/);
75
+ assert.match(content, /prefer concise output/);
76
+ assert.match(content, /## KeepMe/);
77
+ assert.match(content, /Keep this section/);
78
+ });
79
+
80
+ test('trackInsightOutcome updates only matched insights', () => {
81
+ const home = mkHome();
82
+ runWithHome(home, `
83
+ const fs = require('fs');
84
+ const path = require('path');
85
+ const se = require('./scripts/skill-evolution');
86
+ const dir = path.join(process.env.HOME, '.claude', 'skills', 'demo-skill');
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), '# Demo');
89
+ fs.writeFileSync(path.join(dir, 'evolution.json'), JSON.stringify({
90
+ preferences: ['alpha_mode'],
91
+ fixes: ['beta_mode']
92
+ }, null, 2));
93
+ se.trackInsightOutcome(dir, true, {
94
+ prompt: 'please use alpha_mode',
95
+ error: '',
96
+ output_excerpt: '',
97
+ tools_used: [],
98
+ files_modified: []
99
+ });
100
+ `);
101
+
102
+ const evo = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'skills', 'demo-skill', 'evolution.json'), 'utf8'));
103
+ assert.equal(typeof evo.insights_stats, 'object');
104
+ assert.ok(evo.insights_stats.alpha_mode);
105
+ assert.equal(evo.insights_stats.alpha_mode.success_count, 1);
106
+ assert.equal(evo.insights_stats.beta_mode, undefined);
107
+ });