metame-cli 1.4.17 → 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.
@@ -13,6 +13,14 @@ const path = require('path');
13
13
  const os = require('os');
14
14
 
15
15
  const BUFFER_FILE = path.join(os.homedir(), '.metame', 'raw_signals.jsonl');
16
+ const OVERFLOW_FILE = path.join(os.homedir(), '.metame', 'raw_signals.overflow.jsonl');
17
+ const LOCK_FILE = path.join(os.homedir(), '.metame', 'raw_signals.lock');
18
+ const LOCK_RETRY_MAX = 80;
19
+ const LOCK_RETRY_MS = 8;
20
+ const LOCK_STALE_MS = 30 * 1000;
21
+ const MAX_BUFFER_LINES = 300;
22
+ const MAX_CAPTURE_CHARS = 1600;
23
+ const ABSOLUTE_MAX_CAPTURE_CHARS = 6000;
16
24
 
17
25
  // === CONFIDENCE PATTERNS ===
18
26
 
@@ -33,6 +41,90 @@ const CORRECTION_EN = /(no,? I meant|that's not what I|you misunderstood|wrong.+
33
41
  const META_ZH = /我(发现|意识到|觉得|反思|总结|复盘)|想错了|换个(思路|方向|方案)|回头(想想|看看)|之前的(方案|思路|方向).*(不行|不对|有问题)|我的(问题|毛病|习惯)是|下次(应该|要|得)/;
34
42
  const META_EN = /(I realize|looking back|on reflection|my (mistake|problem|habit) is|let me rethink|wrong approach|next time I should)/i;
35
43
 
44
+ // Internal/system prompts must never enter cognition signal buffer.
45
+ const INTERNAL_PROMPT_PATTERNS = [
46
+ /You are a MetaMe cognitive profile distiller/i,
47
+ /You are a metacognition pattern detector/i,
48
+ /你是精准的知识提取引擎/,
49
+ /RECALLED LONG-TERM FACTS \(context only/i,
50
+ /\[System hints - DO NOT mention these to user:/i,
51
+ /\[Mac automation policy - do NOT expose this block:/i,
52
+ /MANDATORY FIRST ACTION: The user has not been calibrated yet/i,
53
+ /<\!--\s*FACTS:START\s*-->/i,
54
+ /<\!--\s*MEMORY:START\s*-->/i,
55
+ /\[Task notification\]/i,
56
+ /<task-notification\b/i,
57
+ ];
58
+
59
+ function sanitizeMetaMePrompt(text) {
60
+ let prompt = String(text || '');
61
+ if (!prompt) return '';
62
+
63
+ // Remove daemon-injected RAG blocks
64
+ prompt = prompt.replace(/<!--\s*FACTS:START\s*-->[\s\S]*?<!--\s*FACTS:END\s*-->/gi, ' ');
65
+ prompt = prompt.replace(/<!--\s*MEMORY:START\s*-->[\s\S]*?<!--\s*MEMORY:END\s*-->/gi, ' ');
66
+
67
+ // Remove daemon/system internal hint blocks
68
+ prompt = prompt.replace(/\[System hints - DO NOT mention these to user:[\s\S]*?\]/gi, ' ');
69
+ prompt = prompt.replace(/\[Mac automation policy - do NOT expose this block:[\s\S]*?\]/gi, ' ');
70
+ prompt = prompt.replace(/\[Task notification\][\s\S]*?(?=\n{2,}|$)/gi, ' ');
71
+ prompt = prompt.replace(/<task-notification\b[\s\S]*?<\/task-notification>/gi, ' ');
72
+ prompt = prompt.replace(/<task-notification\b[\s\S]*$/gi, ' ');
73
+
74
+ return prompt.trim();
75
+ }
76
+
77
+ function isInternalPrompt(text) {
78
+ const prompt = String(text || '');
79
+ if (!prompt) return false;
80
+ return INTERNAL_PROMPT_PATTERNS.some((re) => re.test(prompt));
81
+ }
82
+
83
+ function sleep(ms) {
84
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
85
+ }
86
+
87
+ function withBufferLock(fn) {
88
+ const dir = path.dirname(BUFFER_FILE);
89
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
90
+
91
+ let acquired = false;
92
+ for (let i = 0; i < LOCK_RETRY_MAX; i++) {
93
+ try {
94
+ const fd = fs.openSync(LOCK_FILE, 'wx');
95
+ fs.writeSync(fd, process.pid.toString());
96
+ fs.closeSync(fd);
97
+ acquired = true;
98
+ break;
99
+ } catch (e) {
100
+ if (e.code !== 'EEXIST') throw e;
101
+ try {
102
+ const age = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
103
+ if (age > LOCK_STALE_MS) {
104
+ fs.unlinkSync(LOCK_FILE);
105
+ continue;
106
+ }
107
+ } catch { /* lock released by another process */ }
108
+ sleep(LOCK_RETRY_MS);
109
+ }
110
+ }
111
+
112
+ if (!acquired) return false;
113
+ try {
114
+ fn();
115
+ return true;
116
+ } finally {
117
+ try { fs.unlinkSync(LOCK_FILE); } catch { /* non-fatal */ }
118
+ }
119
+ }
120
+
121
+ function writeBufferAtomically(lines) {
122
+ const tmp = BUFFER_FILE + `.tmp.${process.pid}`;
123
+ const content = lines.length > 0 ? (lines.join('\n') + '\n') : '';
124
+ fs.writeFileSync(tmp, content, 'utf8');
125
+ fs.renameSync(tmp, BUFFER_FILE);
126
+ }
127
+
36
128
  // Read JSON from stdin
37
129
  let input = '';
38
130
  process.stdin.setEncoding('utf8');
@@ -40,7 +132,23 @@ process.stdin.on('data', (chunk) => { input += chunk; });
40
132
  process.stdin.on('end', () => {
41
133
  try {
42
134
  const data = JSON.parse(input);
43
- const prompt = (data.prompt || '').trim();
135
+ let prompt = (data.prompt || '').trim();
136
+
137
+ // Internal Claude subprocesses (distill/memory extract/skill evolution) set this flag.
138
+ if (process.env.METAME_INTERNAL_PROMPT === '1') {
139
+ process.exit(0);
140
+ }
141
+
142
+ // Strip daemon/system injected wrappers first; keep user payload if present.
143
+ prompt = sanitizeMetaMePrompt(prompt);
144
+ if (!prompt) {
145
+ process.exit(0);
146
+ }
147
+
148
+ // Belt-and-suspenders: filter known internal prompt templates.
149
+ if (isInternalPrompt(prompt)) {
150
+ process.exit(0);
151
+ }
44
152
 
45
153
  // === LAYER 0: Metacognitive bypass — always capture self-reflection ===
46
154
  const isMeta = META_ZH.test(prompt) || META_EN.test(prompt);
@@ -55,6 +163,14 @@ process.stdin.on('end', () => {
55
163
  process.exit(0);
56
164
  }
57
165
 
166
+ // Hard cap to prevent giant prompt pastes from poisoning distill budget.
167
+ if (prompt.length > ABSOLUTE_MAX_CAPTURE_CHARS) {
168
+ process.exit(0);
169
+ }
170
+ if (prompt.length > MAX_CAPTURE_CHARS) {
171
+ prompt = prompt.slice(0, MAX_CAPTURE_CHARS);
172
+ }
173
+
58
174
  // Skip messages that are purely code or file paths
59
175
  if (!isMeta && /^(```|\/[\w/]+\.\w+$)/.test(prompt)) {
60
176
  process.exit(0);
@@ -113,18 +229,62 @@ process.stdin.on('end', () => {
113
229
  cwd: data.cwd || null
114
230
  };
115
231
 
116
- // Append to buffer, drop oldest if over cap
117
- let existingLines = [];
118
- try {
119
- existingLines = fs.readFileSync(BUFFER_FILE, 'utf8')
120
- .split('\n').filter(l => l.trim());
121
- } catch {
122
- // File doesn't exist yet, that's fine
123
- }
232
+ // Append/update with process-level lock to avoid concurrent read-modify-write loss.
233
+ const locked = withBufferLock(() => {
234
+ let existingLines = [];
235
+ try {
236
+ existingLines = fs.readFileSync(BUFFER_FILE, 'utf8').split('\n').filter(l => l.trim());
237
+ } catch {
238
+ // File doesn't exist yet, that's fine
239
+ }
240
+
241
+ // Drain overflow written during prior lock-contention periods.
242
+ // unlink AFTER writeBufferAtomically succeeds — crash-safe ordering.
243
+ let overflowDrained = false;
244
+ try {
245
+ const overflowLines = fs.readFileSync(OVERFLOW_FILE, 'utf8').split('\n').filter(Boolean);
246
+ if (overflowLines.length > 0) {
247
+ existingLines = existingLines.concat(overflowLines);
248
+ overflowDrained = true;
249
+ }
250
+ } catch { /* no overflow file — normal case */ }
124
251
 
125
- existingLines.push(JSON.stringify(entry));
252
+ // Opportunistic hygiene: remove old internal/system lines if any slipped in historically.
253
+ existingLines = existingLines.filter((line) => {
254
+ try {
255
+ const parsed = JSON.parse(line);
256
+ const p = String(parsed && parsed.prompt ? parsed.prompt : '');
257
+ if (!p) return false;
258
+ if (p.length > ABSOLUTE_MAX_CAPTURE_CHARS) return false;
259
+ if (isInternalPrompt(p)) return false;
260
+ return true;
261
+ } catch {
262
+ return false;
263
+ }
264
+ });
126
265
 
127
- fs.writeFileSync(BUFFER_FILE, existingLines.join('\n') + '\n');
266
+ existingLines.push(JSON.stringify(entry));
267
+ if (existingLines.length > MAX_BUFFER_LINES) {
268
+ existingLines = existingLines.slice(-MAX_BUFFER_LINES);
269
+ }
270
+ writeBufferAtomically(existingLines);
271
+ // Unlink overflow only after main file is safely written.
272
+ if (overflowDrained) {
273
+ try { fs.unlinkSync(OVERFLOW_FILE); } catch { /* already gone */ }
274
+ }
275
+ });
276
+
277
+ if (!locked) {
278
+ // Last-resort fallback: write to overflow side-file so the next lock-holder
279
+ // can drain, clean, and cap it — never bypassing buffer rules.
280
+ // Guard against unbounded growth: drop entry if overflow already at cap.
281
+ fs.mkdirSync(path.dirname(OVERFLOW_FILE), { recursive: true });
282
+ try {
283
+ const ofLines = fs.readFileSync(OVERFLOW_FILE, 'utf8').split('\n').filter(Boolean);
284
+ if (ofLines.length >= MAX_BUFFER_LINES) return; // shed load
285
+ } catch { /* overflow file doesn't exist yet */ }
286
+ fs.appendFileSync(OVERFLOW_FILE, JSON.stringify(entry) + '\n', 'utf8');
287
+ }
128
288
 
129
289
  } catch {
130
290
  // Silently ignore parse errors — never block the user's workflow
@@ -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
 
@@ -197,20 +246,92 @@ function extractSkillSignal(prompt, output, error, files, cwd, toolUsageLog) {
197
246
  /**
198
247
  * Append a skill signal to the JSONL buffer.
199
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
+
200
292
  function appendSkillSignal(signal) {
201
293
  if (!signal) return;
202
294
  try {
203
- if (!fs.existsSync(METAME_DIR)) fs.mkdirSync(METAME_DIR, { mode: 0o700, recursive: true });
295
+ const payload = JSON.stringify(signal);
296
+ const policy = loadPolicy();
204
297
 
205
- // Append
206
- 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
+ });
207
325
 
208
- // Truncate if over limit (keep newest)
209
- const content = fs.readFileSync(SKILL_SIGNAL_FILE, 'utf8').trim();
210
- const lines = content.split('\n');
211
- const policy = loadPolicy();
212
- if (lines.length > policy.max_signals_buffer) {
213
- 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');
214
335
  }
215
336
  } catch {
216
337
  // Non-fatal
@@ -263,7 +384,12 @@ function checkHotEvolution(signal) {
263
384
  }
264
385
 
265
386
  // Rule 2: User complaints about a skill
266
- 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
+ }
267
393
  if (signal.prompt && complaintRe.test(signal.prompt) && signal.skills_invoked.length > 0) {
268
394
  for (const sk of signal.skills_invoked) {
269
395
  addToQueue(queue, {
@@ -276,7 +402,12 @@ function checkHotEvolution(signal) {
276
402
  }
277
403
 
278
404
  // Rule 3: Missing skill detection
279
- const missingRe = new RegExp(policy.missing_skill_patterns.join('|'), 'i');
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
+ }
280
411
  const missText = [(signal.error || ''), (signal.prompt || ''), (signal.output_excerpt || '')].join('\n');
281
412
  const hasMissingPattern = missingRe.test(missText);
282
413
  const hasExplicitSkillMiss = signal.has_tool_failure &&
@@ -414,7 +545,6 @@ async function distillSkills() {
414
545
  // Get installed skills list
415
546
  const installedSkills = listInstalledSkills();
416
547
  if (installedSkills.length === 0) {
417
- clearSignals();
418
548
  return null;
419
549
  }
420
550
 
@@ -466,7 +596,6 @@ async function distillSkills() {
466
596
 
467
597
  const jsonMatch = result.match(/```json\s*([\s\S]*?)```/);
468
598
  if (!jsonMatch) {
469
- clearSignals();
470
599
  return null;
471
600
  }
472
601
 
@@ -624,8 +753,15 @@ RULES:
624
753
  const patchData = yaml.load(yamlMatch[1]);
625
754
  if (!patchData || typeof patchData !== 'object') return;
626
755
 
627
- // Apply patch (merge, don't overwrite entirely)
628
- 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
+ }
629
765
  savePolicy(yaml, newPolicy);
630
766
  console.log(`🧬 Policy self-evolved: v${policy.version} → v${newPolicy.version}`);
631
767
 
@@ -896,7 +1032,10 @@ function readRecentSignals() {
896
1032
  }
897
1033
 
898
1034
  function clearSignals() {
899
- 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 {}
900
1039
  }
901
1040
 
902
1041
  function listInstalledSkills() {