metame-cli 1.4.15 → 1.4.18

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.
@@ -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
  *
@@ -35,6 +35,8 @@ const os = require('os');
35
35
  const HOME = os.homedir();
36
36
  const METAME_DIR = path.join(HOME, '.metame');
37
37
  const SKILL_SIGNAL_FILE = path.join(METAME_DIR, 'skill_signals.jsonl');
38
+ const SKILL_SIGNAL_OVERFLOW_FILE = path.join(METAME_DIR, 'skill_signals.overflow.jsonl');
39
+ const SKILL_SIGNAL_LOCK_FILE = path.join(METAME_DIR, 'skill_signals.lock');
38
40
  const EVOLUTION_QUEUE_FILE = path.join(METAME_DIR, 'evolution_queue.yaml');
39
41
  const EVOLUTION_POLICY_FILE = path.join(METAME_DIR, 'evolution_policy.yaml');
40
42
  const BRAIN_FILE = path.join(HOME, '.claude_profile.yaml');
@@ -113,6 +115,54 @@ Respond with ONLY a JSON code block:
113
115
  If no actionable insights, respond with exactly: NO_EVOLUTION`,
114
116
  };
115
117
 
118
+ function clampInt(value, fallback, min, max) {
119
+ const n = Number(value);
120
+ if (!Number.isFinite(n)) return fallback;
121
+ const i = Math.floor(n);
122
+ return Math.max(min, Math.min(max, i));
123
+ }
124
+
125
+ function sanitizePatternList(value, fallback) {
126
+ const list = Array.isArray(value) ? value : fallback;
127
+ const clean = [];
128
+ for (const v of list) {
129
+ if (typeof v !== 'string') continue;
130
+ const p = v.trim();
131
+ if (!p || p.length > 300) continue;
132
+ try {
133
+ // Validate regex compileability once so hot path won't crash at runtime.
134
+ // eslint-disable-next-line no-new
135
+ new RegExp(p, 'i');
136
+ clean.push(p);
137
+ } catch { /* invalid pattern */ }
138
+ }
139
+ return clean.length > 0 ? clean : fallback.slice();
140
+ }
141
+
142
+ function sanitizePolicy(input) {
143
+ const merged = { ...DEFAULT_POLICY, ...(input && typeof input === 'object' ? input : {}) };
144
+ const policy = {
145
+ ...merged,
146
+ version: clampInt(merged.version, DEFAULT_POLICY.version, 1, 1000000),
147
+ hot_failure_threshold: clampInt(merged.hot_failure_threshold, DEFAULT_POLICY.hot_failure_threshold, 1, 50),
148
+ hot_failure_window_minutes: clampInt(merged.hot_failure_window_minutes, DEFAULT_POLICY.hot_failure_window_minutes, 1, 24 * 60),
149
+ min_signals_for_distill: clampInt(merged.min_signals_for_distill, DEFAULT_POLICY.min_signals_for_distill, 1, 5000),
150
+ max_signals_buffer: clampInt(merged.max_signals_buffer, DEFAULT_POLICY.max_signals_buffer, 20, 20000),
151
+ min_evidence_for_update: clampInt(merged.min_evidence_for_update, DEFAULT_POLICY.min_evidence_for_update, 1, 20),
152
+ min_evidence_for_gap: clampInt(merged.min_evidence_for_gap, DEFAULT_POLICY.min_evidence_for_gap, 1, 20),
153
+ max_updates_per_analysis: clampInt(merged.max_updates_per_analysis, DEFAULT_POLICY.max_updates_per_analysis, 1, 20),
154
+ max_gaps_per_analysis: clampInt(merged.max_gaps_per_analysis, DEFAULT_POLICY.max_gaps_per_analysis, 1, 20),
155
+ self_eval_interval: clampInt(merged.self_eval_interval, DEFAULT_POLICY.self_eval_interval, 1, 1000),
156
+ cold_path_run_count: clampInt(merged.cold_path_run_count, DEFAULT_POLICY.cold_path_run_count, 0, 1000000),
157
+ complaint_patterns: sanitizePatternList(merged.complaint_patterns, DEFAULT_POLICY.complaint_patterns),
158
+ missing_skill_patterns: sanitizePatternList(merged.missing_skill_patterns, DEFAULT_POLICY.missing_skill_patterns),
159
+ prompt_template: (typeof merged.prompt_template === 'string' && merged.prompt_template.trim())
160
+ ? merged.prompt_template
161
+ : DEFAULT_POLICY.prompt_template,
162
+ };
163
+ return policy;
164
+ }
165
+
116
166
  function loadPolicy() {
117
167
  let yaml;
118
168
  try { yaml = require('js-yaml'); } catch { return { ...DEFAULT_POLICY }; }
@@ -125,8 +175,7 @@ function loadPolicy() {
125
175
  }
126
176
  const content = fs.readFileSync(EVOLUTION_POLICY_FILE, 'utf8');
127
177
  const loaded = yaml.load(content) || {};
128
- // Merge with defaults (new fields auto-added on upgrade)
129
- return { ...DEFAULT_POLICY, ...loaded };
178
+ return sanitizePolicy(loaded);
130
179
  } catch {
131
180
  return { ...DEFAULT_POLICY };
132
181
  }
@@ -135,7 +184,7 @@ function loadPolicy() {
135
184
  function savePolicy(yaml, policy) {
136
185
  try {
137
186
  if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { mode: 0o700, recursive: true });
138
- fs.writeFileSync(EVOLUTION_POLICY_FILE, yaml.dump(policy, { lineWidth: 120 }), 'utf8');
187
+ fs.writeFileSync(EVOLUTION_POLICY_FILE, yaml.dump(sanitizePolicy(policy), { lineWidth: 120 }), 'utf8');
139
188
  } catch {}
140
189
  }
141
190
 
@@ -174,7 +223,8 @@ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
174
223
 
175
224
  const hasSkills = skills.length > 0;
176
225
  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);
226
+ const outputText = typeof output === 'string' ? output : '';
227
+ const hasToolFailure = /(?:failed|error|not found|not available|skill.{0,20}(?:missing|absent|not.{0,10}install))/i.test(outputText);
178
228
 
179
229
  // Skip if no skill involvement and no failure
180
230
  if (!hasSkills && !hasError && !hasToolFailure) return null;
@@ -185,30 +235,103 @@ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
185
235
  skills_invoked: skills,
186
236
  tools_used: tools.slice(0, 20), // cap for storage
187
237
  error: error ? error.substring(0, 500) : null,
238
+ output_excerpt: outputText.substring(0, 500),
188
239
  has_tool_failure: !!hasToolFailure,
189
240
  files_modified: (files || []).slice(0, 10),
190
241
  cwd: cwd || null,
191
- outcome: error ? 'error' : (output ? 'success' : 'empty'),
242
+ outcome: (hasError || hasToolFailure) ? 'error' : (outputText ? 'success' : 'empty'),
192
243
  };
193
244
  }
194
245
 
195
246
  /**
196
247
  * Append a skill signal to the JSONL buffer.
197
248
  */
249
+ function withSkillSignalLock(fn) {
250
+ if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { mode: 0o700, recursive: true });
251
+ const maxRetry = 80;
252
+ const retryMs = 8;
253
+ const staleMs = 30 * 1000;
254
+
255
+ let acquired = false;
256
+ for (let i = 0; i < maxRetry; i++) {
257
+ try {
258
+ const fd = fs.openSync(SKILL_SIGNAL_LOCK_FILE, 'wx');
259
+ fs.writeSync(fd, String(process.pid));
260
+ fs.closeSync(fd);
261
+ acquired = true;
262
+ break;
263
+ } catch (e) {
264
+ if (e.code !== 'EEXIST') throw e;
265
+ try {
266
+ const age = Date.now() - fs.statSync(SKILL_SIGNAL_LOCK_FILE).mtimeMs;
267
+ if (age > staleMs) {
268
+ fs.unlinkSync(SKILL_SIGNAL_LOCK_FILE);
269
+ continue;
270
+ }
271
+ } catch { /* lock released elsewhere */ }
272
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryMs);
273
+ }
274
+ }
275
+
276
+ if (!acquired) return false;
277
+ try {
278
+ fn();
279
+ return true;
280
+ } finally {
281
+ try { fs.unlinkSync(SKILL_SIGNAL_LOCK_FILE); } catch {}
282
+ }
283
+ }
284
+
285
+ function writeSkillSignalLines(lines) {
286
+ const tmp = SKILL_SIGNAL_FILE + `.tmp.${process.pid}`;
287
+ const content = Array.isArray(lines) && lines.length > 0 ? lines.join('\n') + '\n' : '';
288
+ fs.writeFileSync(tmp, content, 'utf8');
289
+ fs.renameSync(tmp, SKILL_SIGNAL_FILE);
290
+ }
291
+
198
292
  function appendSkillSignal(signal) {
199
293
  if (!signal) return;
200
294
  try {
201
- if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { mode: 0o700, recursive: true });
295
+ const payload = JSON.stringify(signal);
296
+ const policy = loadPolicy();
202
297
 
203
- // Append
204
- fs.appendFileSync(SKILL_SIGNAL_FILE, JSON.stringify(signal) + '\n', 'utf8');
298
+ const locked = withSkillSignalLock(() => {
299
+ let lines = [];
300
+ try {
301
+ lines = fs.readFileSync(SKILL_SIGNAL_FILE, 'utf8').split('\n').filter(Boolean);
302
+ } catch { /* first write */ }
303
+
304
+ // Drain overflow written during prior lock-contention periods.
305
+ // unlink AFTER writeSkillSignalLines succeeds — crash-safe ordering.
306
+ let overflowDrained = false;
307
+ try {
308
+ const overflowLines = fs.readFileSync(SKILL_SIGNAL_OVERFLOW_FILE, 'utf8').split('\n').filter(Boolean);
309
+ if (overflowLines.length > 0) {
310
+ lines = lines.concat(overflowLines);
311
+ overflowDrained = true;
312
+ }
313
+ } catch { /* no overflow file — normal case */ }
314
+
315
+ lines.push(payload);
316
+ if (lines.length > policy.max_signals_buffer) {
317
+ lines = lines.slice(-policy.max_signals_buffer);
318
+ }
319
+ writeSkillSignalLines(lines);
320
+ // Unlink overflow only after main file is safely written.
321
+ if (overflowDrained) {
322
+ try { fs.unlinkSync(SKILL_SIGNAL_OVERFLOW_FILE); } catch { /* already gone */ }
323
+ }
324
+ });
205
325
 
206
- // Truncate if over limit (keep newest)
207
- const content = fs.readFileSync(SKILL_SIGNAL_FILE, 'utf8').trim();
208
- const lines = content.split('\n');
209
- const policy = loadPolicy();
210
- if (lines.length > policy.max_signals_buffer) {
211
- fs.writeFileSync(SKILL_SIGNAL_FILE, lines.slice(-policy.max_signals_buffer).join('\n') + '\n', 'utf8');
326
+ if (!locked) {
327
+ // Last-resort fallback: write to overflow side-file so the next lock-holder
328
+ // can drain and apply max_signals_buffer cap — never bypassing buffer rules.
329
+ // Guard against unbounded growth: drop entry if overflow already at cap.
330
+ try {
331
+ const ofLines = fs.readFileSync(SKILL_SIGNAL_OVERFLOW_FILE, 'utf8').split('\n').filter(Boolean);
332
+ if (ofLines.length >= policy.max_signals_buffer) return;
333
+ } catch { /* overflow file doesn't exist yet */ }
334
+ fs.appendFileSync(SKILL_SIGNAL_OVERFLOW_FILE, payload + '\n', 'utf8');
212
335
  }
213
336
  } catch {
214
337
  // Non-fatal
@@ -261,7 +384,12 @@ function checkHotEvolution(signal) {
261
384
  }
262
385
 
263
386
  // Rule 2: User complaints about a skill
264
- const complaintRe = new RegExp(policy.complaint_patterns.join('|'), 'i');
387
+ let complaintRe;
388
+ try {
389
+ complaintRe = new RegExp(policy.complaint_patterns.join('|'), 'i');
390
+ } catch {
391
+ complaintRe = new RegExp(DEFAULT_POLICY.complaint_patterns.join('|'), 'i');
392
+ }
265
393
  if (signal.prompt && complaintRe.test(signal.prompt) && signal.skills_invoked.length > 0) {
266
394
  for (const sk of signal.skills_invoked) {
267
395
  addToQueue(queue, {
@@ -274,8 +402,18 @@ function checkHotEvolution(signal) {
274
402
  }
275
403
 
276
404
  // Rule 3: Missing skill detection
277
- const missingRe = new RegExp(policy.missing_skill_patterns.join('|'), 'i');
278
- if (signal.outcome !== 'success' && signal.prompt && missingRe.test((signal.error || '') + (signal.prompt || ''))) {
405
+ let missingRe;
406
+ try {
407
+ missingRe = new RegExp(policy.missing_skill_patterns.join('|'), 'i');
408
+ } catch {
409
+ missingRe = new RegExp(DEFAULT_POLICY.missing_skill_patterns.join('|'), 'i');
410
+ }
411
+ const missText = [(signal.error || ''), (signal.prompt || ''), (signal.output_excerpt || '')].join('\n');
412
+ const hasMissingPattern = missingRe.test(missText);
413
+ const hasExplicitSkillMiss = signal.has_tool_failure &&
414
+ /skill|技能|能力/i.test(missText) &&
415
+ /not found|missing|absent|no skill|找不到|没有找到|能力不足/i.test(missText);
416
+ if (signal.prompt && (hasMissingPattern || hasExplicitSkillMiss)) {
279
417
  addToQueue(queue, {
280
418
  type: 'skill_gap',
281
419
  skill_name: null,
@@ -295,7 +433,7 @@ function checkHotEvolution(signal) {
295
433
  if (isSuccess || isFail) {
296
434
  for (const sk of signal.skills_invoked) {
297
435
  const skillDir = findSkillDir(sk);
298
- if (skillDir) trackInsightOutcome(skillDir, isSuccess);
436
+ if (skillDir) trackInsightOutcome(skillDir, isSuccess, signal);
299
437
  }
300
438
  }
301
439
  }
@@ -305,7 +443,51 @@ function checkHotEvolution(signal) {
305
443
  * Update insight outcome stats in evolution.json for a skill.
306
444
  * Tracks success_count, fail_count, last_applied_at per insight text.
307
445
  */
308
- function trackInsightOutcome(skillDir, isSuccess) {
446
+ const INSIGHT_STOPWORDS = new Set([
447
+ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'where',
448
+ 'user', 'skill', 'meta', 'metame', 'should', 'always', 'never', 'please',
449
+ '问题', '用户', '技能', '需要', '应该', '这个', '那个', '以及', '如果', '然后',
450
+ ]);
451
+
452
+ function extractInsightTokens(text) {
453
+ const raw = String(text || '').toLowerCase();
454
+ const en = raw.match(/[a-z][a-z0-9_.-]{2,}/g) || [];
455
+ const zh = raw.match(/[\u4e00-\u9fff]{2,}/g) || [];
456
+ const tokens = [...en, ...zh]
457
+ .map(t => t.trim())
458
+ .filter(t => t.length >= 2 && !INSIGHT_STOPWORDS.has(t));
459
+ return [...new Set(tokens)];
460
+ }
461
+
462
+ function pickMatchedInsights(allInsights, signal) {
463
+ if (!signal || !Array.isArray(allInsights) || allInsights.length === 0) return [];
464
+
465
+ const context = [
466
+ signal.prompt || '',
467
+ signal.error || '',
468
+ signal.output_excerpt || '',
469
+ ...(Array.isArray(signal.tools_used) ? signal.tools_used.map(t => `${t.name || ''} ${t.context || ''}`) : []),
470
+ ...(Array.isArray(signal.files_modified) ? signal.files_modified : []),
471
+ ].join('\n').toLowerCase();
472
+
473
+ const scored = allInsights
474
+ .map((insight) => {
475
+ const tokens = extractInsightTokens(insight).slice(0, 12);
476
+ if (tokens.length === 0) return { insight, score: 0 };
477
+ let score = 0;
478
+ for (const token of tokens) {
479
+ if (context.includes(token)) score++;
480
+ }
481
+ return { insight, score };
482
+ })
483
+ .filter(x => x.score > 0)
484
+ .sort((a, b) => b.score - a.score);
485
+
486
+ if (scored.length > 0) return scored.slice(0, 3).map(x => x.insight);
487
+ return allInsights.length === 1 ? [allInsights[0]] : [];
488
+ }
489
+
490
+ function trackInsightOutcome(skillDir, isSuccess, signal = null) {
309
491
  const evoPath = path.join(skillDir, 'evolution.json');
310
492
  let data = {};
311
493
  try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
@@ -317,8 +499,9 @@ function trackInsightOutcome(skillDir, isSuccess) {
317
499
  ...(data.fixes || []),
318
500
  ...(data.contexts || []),
319
501
  ];
502
+ const targetInsights = signal ? pickMatchedInsights(allInsights, signal) : allInsights;
320
503
 
321
- for (const insight of allInsights) {
504
+ for (const insight of targetInsights) {
322
505
  if (!data.insights_stats[insight]) {
323
506
  data.insights_stats[insight] = { success_count: 0, fail_count: 0, last_applied_at: null };
324
507
  }
@@ -333,7 +516,7 @@ function trackInsightOutcome(skillDir, isSuccess) {
333
516
 
334
517
  // ─────────────────────────────────────────────
335
518
  // Cold Path: Haiku-Powered Analysis
336
- // (called from distill.js)
519
+ // (called by heartbeat script task: skill-evolve)
337
520
  // ─────────────────────────────────────────────
338
521
 
339
522
  /**
@@ -362,7 +545,6 @@ async function distillSkills() {
362
545
  // Get installed skills list
363
546
  const installedSkills = listInstalledSkills();
364
547
  if (installedSkills.length === 0) {
365
- clearSignals();
366
548
  return null;
367
549
  }
368
550
 
@@ -414,7 +596,6 @@ async function distillSkills() {
414
596
 
415
597
  const jsonMatch = result.match(/```json\s*([\s\S]*?)```/);
416
598
  if (!jsonMatch) {
417
- clearSignals();
418
599
  return null;
419
600
  }
420
601
 
@@ -572,8 +753,15 @@ RULES:
572
753
  const patchData = yaml.load(yamlMatch[1]);
573
754
  if (!patchData || typeof patchData !== 'object') return;
574
755
 
575
- // Apply patch (merge, don't overwrite entirely)
576
- const newPolicy = { ...policy, ...patchData };
756
+ // Apply patch with schema/type sanitization.
757
+ const newPolicy = sanitizePolicy({ ...policy, ...patchData });
758
+ if (newPolicy.version <= policy.version) {
759
+ newPolicy.version = policy.version + 1;
760
+ }
761
+ if (JSON.stringify(newPolicy) === JSON.stringify(policy)) {
762
+ console.log('🧬 Policy self-eval: patch produced no effective change.');
763
+ return;
764
+ }
577
765
  savePolicy(yaml, newPolicy);
578
766
  console.log(`🧬 Policy self-evolved: v${policy.version} → v${newPolicy.version}`);
579
767
 
@@ -591,7 +779,16 @@ function loadEvolutionQueue(yaml) {
591
779
  if (!fs.existsSync(EVOLUTION_QUEUE_FILE)) return { items: [] };
592
780
  const content = fs.readFileSync(EVOLUTION_QUEUE_FILE, 'utf8');
593
781
  const data = yaml.load(content);
594
- return data && Array.isArray(data.items) ? data : { items: [] };
782
+ const queue = data && Array.isArray(data.items) ? data : { items: [] };
783
+ let changed = false;
784
+ for (const item of queue.items) {
785
+ if (!item.id) {
786
+ item.id = `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
787
+ changed = true;
788
+ }
789
+ }
790
+ if (changed) saveEvolutionQueue(yaml, queue);
791
+ return queue;
595
792
  } catch {
596
793
  return { items: [] };
597
794
  }
@@ -604,18 +801,24 @@ function saveEvolutionQueue(yaml, queue) {
604
801
  }
605
802
 
606
803
  function addToQueue(queue, entry) {
607
- // Dedup by type + skill_name (update existing instead of adding duplicate)
804
+ // Dedup pending entries by core key. Skill gaps also include search_hint so unrelated gaps don't collapse.
608
805
  const existing = queue.items.find(i =>
609
- i.type === entry.type && i.skill_name === entry.skill_name && i.status === 'pending'
806
+ i.type === entry.type &&
807
+ i.skill_name === entry.skill_name &&
808
+ i.status === 'pending' &&
809
+ (entry.type !== 'skill_gap' || (i.search_hint || '') === (entry.search_hint || ''))
610
810
  );
611
811
 
612
812
  if (existing) {
613
813
  existing.evidence_count = (existing.evidence_count || 0) + (entry.evidence_count || 1);
614
814
  existing.last_seen = new Date().toISOString();
815
+ if (entry.reason) existing.reason = entry.reason;
816
+ if (entry.search_hint) existing.search_hint = entry.search_hint;
615
817
  return;
616
818
  }
617
819
 
618
820
  queue.items.push({
821
+ id: `q-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
619
822
  ...entry,
620
823
  detected: new Date().toISOString(),
621
824
  last_seen: new Date().toISOString(),
@@ -639,13 +842,11 @@ function checkEvolutionQueue() {
639
842
 
640
843
  const queue = loadEvolutionQueue(yaml);
641
844
  const pendingItems = queue.items.filter(i => i.status === 'pending');
642
- if (pendingItems.length === 0) return [];
643
-
644
845
  const notifications = [];
846
+ const policy = loadPolicy();
645
847
 
646
848
  for (const item of pendingItems) {
647
849
  // Require minimum evidence before notifying
648
- const policy = loadPolicy();
649
850
  const minEvidence = item.type === 'skill_gap' ? policy.min_evidence_for_gap : policy.min_evidence_for_update;
650
851
  if ((item.evidence_count || 1) < minEvidence) continue;
651
852
 
@@ -655,13 +856,14 @@ function checkEvolutionQueue() {
655
856
  }
656
857
 
657
858
  // Prune old resolved items (> 30 days)
859
+ const beforeLen = queue.items.length;
658
860
  const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
659
861
  queue.items = queue.items.filter(i =>
660
862
  i.status === 'pending' || i.status === 'notified' ||
661
863
  (new Date(i.last_seen || i.detected).getTime() > cutoff)
662
864
  );
663
865
 
664
- if (notifications.length > 0) {
866
+ if (notifications.length > 0 || queue.items.length !== beforeLen) {
665
867
  saveEvolutionQueue(yaml, queue);
666
868
  }
667
869
 
@@ -687,6 +889,42 @@ function resolveQueueItem(type, skillName, resolution) {
687
889
  }
688
890
  }
689
891
 
892
+ /**
893
+ * Mark queue item resolved by queue id.
894
+ * Returns true when updated.
895
+ */
896
+ function resolveQueueItemById(id, resolution) {
897
+ let yaml;
898
+ try { yaml = require('js-yaml'); } catch { return false; }
899
+ if (!id) return false;
900
+
901
+ const queue = loadEvolutionQueue(yaml);
902
+ const item = queue.items.find(i =>
903
+ i.id === id && (i.status === 'pending' || i.status === 'notified')
904
+ );
905
+ if (!item) return false;
906
+
907
+ item.status = resolution; // 'installed' | 'dismissed'
908
+ item.resolved_at = new Date().toISOString();
909
+ saveEvolutionQueue(yaml, queue);
910
+ return true;
911
+ }
912
+
913
+ /**
914
+ * List queue items for manual triage.
915
+ */
916
+ function listQueueItems({ status = null, limit = 20 } = {}) {
917
+ let yaml;
918
+ try { yaml = require('js-yaml'); } catch { return []; }
919
+ const queue = loadEvolutionQueue(yaml);
920
+ const items = Array.isArray(queue.items) ? queue.items : [];
921
+ const filtered = status ? items.filter(i => i.status === status) : items;
922
+ return filtered
923
+ .slice()
924
+ .sort((a, b) => new Date(b.last_seen || b.detected || 0).getTime() - new Date(a.last_seen || a.detected || 0).getTime())
925
+ .slice(0, Math.max(1, limit));
926
+ }
927
+
690
928
  // ─────────────────────────────────────────────
691
929
  // Evolution.json Merge (JS port of merge_evolution.py)
692
930
  // ─────────────────────────────────────────────
@@ -726,8 +964,11 @@ function smartStitch(skillDir) {
726
964
  try { data = JSON.parse(fs.readFileSync(evoPath, 'utf8')); } catch { return; }
727
965
 
728
966
  // Build evolution section
967
+ const AUTO_START = '<!-- METAME-EVOLUTION:START -->';
968
+ const AUTO_END = '<!-- METAME-EVOLUTION:END -->';
729
969
  const sections = [];
730
- sections.push('\n\n## User-Learned Best Practices & Constraints');
970
+ sections.push(`\n\n${AUTO_START}`);
971
+ sections.push('\n## User-Learned Best Practices & Constraints');
731
972
  sections.push('\n> **Auto-Generated Section**: Maintained by skill-evolution-manager. Do not edit manually.');
732
973
 
733
974
  // Helper: get quality indicator for an insight based on stats
@@ -755,13 +996,17 @@ function smartStitch(skillDir) {
755
996
  sections.push(`\n${data.custom_prompts}`);
756
997
  }
757
998
 
999
+ sections.push(`\n${AUTO_END}\n`);
758
1000
  const evolutionBlock = sections.join('\n');
759
1001
 
760
1002
  let content = fs.readFileSync(skillMdPath, 'utf8');
761
- const pattern = /(\n+## User-Learned Best Practices & Constraints[\s\S]*$)/;
1003
+ const markerPattern = new RegExp(`${AUTO_START}[\\s\\S]*?${AUTO_END}\\n?`);
1004
+ const legacyPattern = /\n+## User-Learned Best Practices & Constraints[\s\S]*?(?=\n##\s+|\n#\s+|$)/;
762
1005
 
763
- if (pattern.test(content)) {
764
- content = content.replace(pattern, evolutionBlock);
1006
+ if (markerPattern.test(content)) {
1007
+ content = content.replace(markerPattern, evolutionBlock.trimStart());
1008
+ } else if (legacyPattern.test(content)) {
1009
+ content = content.replace(legacyPattern, evolutionBlock);
765
1010
  } else {
766
1011
  content = content + evolutionBlock;
767
1012
  }
@@ -787,7 +1032,10 @@ function readRecentSignals() {
787
1032
  }
788
1033
 
789
1034
  function clearSignals() {
790
- try { fs.writeFileSync(SKILL_SIGNAL_FILE, '', 'utf8'); } catch {}
1035
+ try {
1036
+ const locked = withSkillSignalLock(() => writeSkillSignalLines([]));
1037
+ if (!locked) fs.writeFileSync(SKILL_SIGNAL_FILE, '', 'utf8');
1038
+ } catch {}
791
1039
  }
792
1040
 
793
1041
  function listInstalledSkills() {
@@ -827,6 +1075,8 @@ module.exports = {
827
1075
  distillSkills,
828
1076
  checkEvolutionQueue,
829
1077
  resolveQueueItem,
1078
+ resolveQueueItemById,
1079
+ listQueueItems,
830
1080
  mergeEvolution,
831
1081
  smartStitch,
832
1082
  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
+ });