principles-disciple 1.55.0 → 1.57.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.
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.55.0",
5
+ "version": "1.57.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.55.0",
3
+ "version": "1.57.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -67,6 +67,7 @@
67
67
  }
68
68
  },
69
69
  "dependencies": {
70
+ "@principles/core": "^0.1.0",
70
71
  "@sinclair/typebox": "^0.34.48",
71
72
  "better-sqlite3": "^12.9.0",
72
73
  "lucide-react": "^1.7.0",
@@ -17,7 +17,7 @@
17
17
  * --help Show help message
18
18
  */
19
19
 
20
- import { copyFileSync, cpSync, existsSync, rmSync, readFileSync, readFileSync as readFileSyncRaw, mkdirSync, writeFileSync, readdirSync } from 'fs';
20
+ import { copyFileSync, cpSync, existsSync, lstatSync, rmSync, readFileSync, readFileSync as readFileSyncRaw, mkdirSync, writeFileSync, readdirSync } from 'fs';
21
21
  import { createHash } from 'crypto';
22
22
  import { join, dirname } from 'path';
23
23
  import { fileURLToPath } from 'url';
@@ -80,7 +80,7 @@ function parseArgs() {
80
80
  skipBuild: false,
81
81
  skipDeps: false,
82
82
  force: false,
83
- restart: false,
83
+ restart: true,
84
84
  dev: false,
85
85
  bump: false,
86
86
  help: false,
@@ -100,7 +100,8 @@ function parseArgs() {
100
100
  args.skipDeps = true;
101
101
  break;
102
102
  case '--restart':
103
- args.restart = true;
103
+ case '--no-restart':
104
+ args.restart = !arg.startsWith('--no-');
104
105
  break;
105
106
  case '--dev':
106
107
  case '-d':
@@ -145,7 +146,7 @@ Options:
145
146
  --lang <zh|en> Language for skills (default: zh)
146
147
  --skip-build Skip build step (use existing dist/)
147
148
  --skip-deps Skip dependency installation
148
- --restart Automatically restart OpenClaw gateway after installation
149
+ --restart Automatically restart OpenClaw gateway after installation (default: true, use --no-restart to skip)
149
150
  --dev, -d Developer mode: --force + --restart + --bump + clean stale backups
150
151
  --bump, -b Auto-bump patch version if there are uncommitted source changes
151
152
  --force, -f Force overwrite without prompts
@@ -610,6 +611,47 @@ function syncItem(item) {
610
611
  }
611
612
  }
612
613
 
614
+ /**
615
+ * Recursive directory copy (Windows-safe, no symlinks).
616
+ */
617
+ function copyDir(src, dest) {
618
+ mkdirSync(dest, { recursive: true });
619
+ for (const entry of readdirSync(src)) {
620
+ const srcPath = join(src, entry);
621
+ const destPath = join(dest, entry);
622
+ if (lstatSync(srcPath).isDirectory()) {
623
+ copyDir(srcPath, destPath);
624
+ } else {
625
+ copyFileSync(srcPath, destPath);
626
+ }
627
+ }
628
+ }
629
+
630
+ /**
631
+ * Inject local workspace packages (monorepo) into node_modules before npm install.
632
+ * @principles/core is a workspace package, not published to npm — we must copy it
633
+ * from the monorepo's node_modules so npm install --production doesn't 404 it.
634
+ */
635
+ function injectLocalWorkspacePackages() {
636
+ const monorepoModules = join(SOURCE_DIR, '..', '..', 'node_modules', '@principles', 'core');
637
+ const targetModules = join(INSTALL_DIR, 'node_modules', '@principles', 'core');
638
+
639
+ if (!existsSync(monorepoModules)) {
640
+ // Not in monorepo context (e.g., npm pack / CI tarball) — skip
641
+ return;
642
+ }
643
+
644
+ console.log(' 📦 Injecting local workspace packages (@principles/core)...');
645
+ mkdirSync(dirname(targetModules), { recursive: true });
646
+ // cpSync creates symlinks on Windows for symlinked dirs — use cp -rL (dereference) via exec
647
+ try {
648
+ execSync(`cp -rL "${monorepoModules}" "${targetModules}"`, { stdio: 'ignore' });
649
+ } catch {
650
+ // Fallback: manual copy via node (Windows-compatible)
651
+ copyDir(monorepoModules, targetModules);
652
+ }
653
+ }
654
+
613
655
  /**
614
656
  * Install production dependencies in target.
615
657
  */
@@ -844,6 +886,7 @@ function main() {
844
886
  for (const item of SYNC_ITEMS) syncItem(item);
845
887
  syncSkills(args.lang);
846
888
 
889
+ injectLocalWorkspacePackages();
847
890
  installTargetDependencies();
848
891
 
849
892
  console.log('\n🔍 Verifying installed plugin can load native dependencies...');
@@ -51,6 +51,9 @@ export class EventLog {
51
51
  private currentEventsFile: string | undefined;
52
52
  private currentDate: string | undefined;
53
53
 
54
+ // Pain score sum per date (for avgScore calculation)
55
+ private readonly painScoreSums: Map<string, number> = new Map();
56
+
54
57
  constructor(stateDir: string, logger?: PluginLogger) {
55
58
  this.logsDir = path.join(stateDir, 'logs');
56
59
  if (!fs.existsSync(this.logsDir)) {
@@ -156,6 +159,10 @@ export class EventLog {
156
159
  recordEvolutionTask(data: EvolutionTaskEventData): void {
157
160
  this.record('evolution_task', 'enqueued', undefined, data);
158
161
  }
162
+
163
+ recordEvolutionTaskCompleted(data: EvolutionTaskEventData): void {
164
+ this.record('evolution_task', 'completed', undefined, data);
165
+ }
159
166
 
160
167
  recordDeepReflection(sessionId: string | undefined, data: DeepReflectionEventData): void {
161
168
  const category = data.passed ? 'passed' : data.timeout ? 'failure' : 'completed';
@@ -238,6 +245,18 @@ export class EventLog {
238
245
  stats.pain.signalsDetected++;
239
246
  stats.pain.maxScore = Math.max(stats.pain.maxScore, data.score);
240
247
 
248
+ // Track signals by source
249
+ if (data.source) {
250
+ stats.pain.signalsBySource[data.source] = (stats.pain.signalsBySource[data.source] || 0) + 1;
251
+ }
252
+
253
+ // Accumulate score for avg calculation
254
+ const currentSum = this.painScoreSums.get(entry.date) ?? 0;
255
+ this.painScoreSums.set(entry.date, currentSum + (data.score || 0));
256
+ stats.pain.avgScore = stats.pain.signalsDetected > 0
257
+ ? Math.round((currentSum + (data.score || 0)) / stats.pain.signalsDetected)
258
+ : 0;
259
+
241
260
  // Update empathy stats for user_empathy source
242
261
  if (data.source === 'user_empathy') {
243
262
  if (data.deduped) {
@@ -291,6 +310,20 @@ export class EventLog {
291
310
  const data = entry.data as unknown as EmpathyRollbackEventData;
292
311
  stats.empathy.rollbackCount++;
293
312
  stats.empathy.rolledBackScore += data.originalScore || 0;
313
+ } else if (entry.type === 'rule_match') {
314
+ const data = entry.data as unknown as RuleMatchEventData;
315
+ if (data.ruleId) {
316
+ stats.pain.rulesMatched[data.ruleId] = (stats.pain.rulesMatched[data.ruleId] || 0) + 1;
317
+ }
318
+ } else if (entry.type === 'rule_promotion') {
319
+ stats.pain.candidatesPromoted++;
320
+ stats.evolution.rulesPromoted++;
321
+ } else if (entry.type === 'evolution_task') {
322
+ if (entry.category === 'completed') {
323
+ stats.evolution.tasksCompleted++;
324
+ } else if (entry.category === 'enqueued') {
325
+ stats.evolution.tasksEnqueued++;
326
+ }
294
327
  }
295
328
  }
296
329
 
@@ -260,7 +260,7 @@ export class EvolutionLogger {
260
260
  logCompleted(params: {
261
261
  traceId: string;
262
262
  taskId: string;
263
- resolution: 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'diagnostician_timeout';
263
+ resolution: 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'diagnostician_timeout' | 'noise_classified' | 'duplicate' | 'serverDuplicate';
264
264
  durationMs?: number;
265
265
  principlesGenerated?: number;
266
266
  }): void {
@@ -271,6 +271,8 @@ export class EvolutionLogger {
271
271
  summary = `任务 ${params.taskId} 完成,已生成 ${params.principlesGenerated || 0} 条原则`;
272
272
  } else if (params.resolution === 'auto_completed_timeout' || params.resolution === 'diagnostician_timeout' || params.resolution === 'late_marker_no_principle') {
273
273
  summary = `任务 ${params.taskId} 超时自动完成`;
274
+ } else if (params.resolution === 'noise_classified' || params.resolution === 'duplicate' || params.resolution === 'serverDuplicate') {
275
+ summary = `任务 ${params.taskId} 分类为噪音/重复,已过滤`;
274
276
  } else {
275
277
  summary = `任务 ${params.taskId} 已完成`;
276
278
  }
@@ -470,7 +470,7 @@ export type EvolutionLoopEvent =
470
470
 
471
471
  // V2 Queue Types (moved from evolution-worker.ts for shared use)
472
472
  export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
473
- export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'success' | 'failure' | 'skipped';
473
+ export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'success' | 'failure' | 'skipped' | 'noise_classified';
474
474
 
475
475
  export interface EvolutionQueueItem {
476
476
  id: string;
@@ -235,7 +235,7 @@ function persistBaselines(stateDir: string, baselines: ObservabilityBaselines):
235
235
  fs.mkdirSync(dir, { recursive: true });
236
236
  }
237
237
  atomicWriteFileSync(filePath, JSON.stringify(baselines, null, 2));
238
- } catch (err) {
238
+ } catch {
239
239
  // Baselines persistence is best-effort — don't crash the caller
240
240
  // (the SystemLogger call above already logged the values)
241
241
  }
@@ -26,7 +26,8 @@ export type TaskResolution =
26
26
  | 'late_marker_principle_created'
27
27
  | 'late_marker_no_principle'
28
28
  | 'stub_fallback'
29
- | 'skipped_thin_violation';
29
+ | 'skipped_thin_violation'
30
+ | 'noise_classified';
30
31
 
31
32
  /**
32
33
  * Recent pain context for sleep_reflection tasks.
@@ -109,7 +109,7 @@ let timeoutId: NodeJS.Timeout | null = null;
109
109
  * Old queue items (without taskKind) are migrated to pain_diagnosis for compatibility.
110
110
  */
111
111
  export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
112
- export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation';
112
+ export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'runtime_unavailable' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'stub_fallback' | 'skipped_thin_violation' | 'noise_classified';
113
113
 
114
114
  export interface EvolutionQueueItem {
115
115
  // Core identity
@@ -921,11 +921,27 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
921
921
  if (fs.existsSync(completeMarker)) {
922
922
  if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} completed - marker file detected`);
923
923
 
924
+ let principlesGenerated = 0;
924
925
  // Create principle from the diagnostician's JSON report.
925
926
  const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
926
927
  if (fs.existsSync(reportPath)) {
927
928
  try {
928
929
  const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
930
+
931
+ // ── Step 3: Noise Classification Filter ──
932
+ // Skip principle creation for low-value noise categories that don't represent
933
+ // systemic failures or behavioral issues worth encoding as principles.
934
+ const classification = reportData?.classification;
935
+ const noiseCategories: Record<string, boolean> = {
936
+ 'development_transient': true, // CRLF drift, duplicate match, self-resolved dev issues
937
+ 'user_error': true, // User mistakes, wrong file, bad input
938
+ };
939
+ if (classification?.category && noiseCategories[classification.category]) {
940
+ if (logger) logger.info(`[PD:EvolutionWorker] Skipping principle for noise category "${classification.category}" — pain was ${classification.severity || 'low'} severity, not a systemic failure`);
941
+ task.status = 'completed';
942
+ task.completed_at = new Date().toISOString();
943
+ task.resolution = 'noise_classified';
944
+ } else {
929
945
  // Check ALL known nesting paths — matches subagent.ts parseDiagnosticianReport
930
946
  const principle = reportData?.principle
931
947
  || reportData?.phases?.principle_extraction?.principle
@@ -1006,6 +1022,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1006
1022
  });
1007
1023
  if (principleId) {
1008
1024
  logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from marker fallback for task ${task.id}`);
1025
+ principlesGenerated = 1;
1009
1026
  } else {
1010
1027
  logger.warn(`[PD:EvolutionWorker] createPrincipleFromDiagnosis returned null for task ${task.id} (may be duplicate or blacklisted)`);
1011
1028
  }
@@ -1017,6 +1034,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1017
1034
  } else {
1018
1035
  logger.warn(`[PD:EvolutionWorker] Diagnostician report for task ${task.id} missing principle fields — diagnostician did not produce a principle`);
1019
1036
  }
1037
+ }
1020
1038
  } catch (err) {
1021
1039
  logger.warn(`[PD:EvolutionWorker] Failed to parse diagnostician report for task ${task.id}: ${String(err)}`);
1022
1040
  }
@@ -1026,7 +1044,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1026
1044
 
1027
1045
  task.status = 'completed';
1028
1046
  task.completed_at = new Date().toISOString();
1029
- task.resolution = 'marker_detected';
1047
+ // resolution already set by each branch (noise_classified | marker_detected)
1048
+ if (!task.resolution) task.resolution = 'marker_detected';
1030
1049
  try {
1031
1050
  fs.unlinkSync(completeMarker);
1032
1051
  } catch { /* marker may have been deleted already, not critical */ }
@@ -1047,22 +1066,30 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1047
1066
  evoLogger.logCompleted({
1048
1067
  traceId: task.traceId || task.id,
1049
1068
  taskId: task.id,
1050
- resolution: 'marker_detected',
1069
+ resolution: task.resolution as 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'diagnostician_timeout',
1051
1070
  durationMs,
1071
+ principlesGenerated,
1072
+ });
1073
+
1074
+ // Record task completion in event stats
1075
+ eventLog.recordEvolutionTaskCompleted({
1076
+ taskId: task.id,
1077
+ taskType: task.source || 'unknown',
1078
+ reason: task.reason || '',
1052
1079
  });
1053
1080
 
1054
1081
  // Update evolution_tasks table
1055
1082
  wctx.trajectory?.updateEvolutionTask?.(task.id, {
1056
1083
  status: 'completed',
1057
1084
  completedAt: task.completed_at,
1058
- resolution: 'marker_detected',
1085
+ resolution: task.resolution as 'marker_detected' | 'auto_completed_timeout' | 'manual' | 'late_marker_principle_created' | 'late_marker_no_principle' | 'diagnostician_timeout',
1059
1086
  });
1060
1087
 
1061
1088
  wctx.trajectory?.recordTaskOutcome({
1062
1089
  sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
1063
1090
  taskId: task.id,
1064
1091
  outcome: 'ok',
1065
- summary: `Task ${task.id} completed - marker file detected.`
1092
+ summary: `Task ${task.id} completed ${principlesGenerated} principle(s) generated (${task.resolution}).`
1066
1093
  });
1067
1094
  queueChanged = true;
1068
1095
  continue;
@@ -1075,9 +1102,11 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1075
1102
  const timeoutCompleteMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
1076
1103
  const timeoutReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1077
1104
 
1105
+ let principlesGenerated = 0;
1106
+
1107
+
1078
1108
  if (fs.existsSync(timeoutCompleteMarker) && fs.existsSync(timeoutReportPath)) {
1079
1109
  if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} timed out but marker found — creating principle anyway`);
1080
- let principleCreated = false;
1081
1110
  try {
1082
1111
  const reportData = JSON.parse(fs.readFileSync(timeoutReportPath, 'utf8'));
1083
1112
  const principle = reportData?.principle
@@ -1103,7 +1132,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1103
1132
  });
1104
1133
  if (principleId) {
1105
1134
  logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from late marker for task ${task.id}`);
1106
- principleCreated = true;
1135
+ principlesGenerated = 1;
1107
1136
  }
1108
1137
  }
1109
1138
  }
@@ -1116,7 +1145,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1116
1145
  const lateReportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
1117
1146
  if (fs.existsSync(lateReportPath)) fs.unlinkSync(lateReportPath);
1118
1147
  } catch { /* report may not exist, not critical */ }
1119
- task.resolution = principleCreated ? 'late_marker_principle_created' : 'late_marker_no_principle';
1148
+ task.resolution = principlesGenerated > 0 ? 'late_marker_principle_created' : 'late_marker_no_principle';
1120
1149
  } else {
1121
1150
  if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout`);
1122
1151
  // #190: Clean up diagnostician report file even on timeout (may have been written late)
@@ -1137,6 +1166,14 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1137
1166
  taskId: task.id,
1138
1167
  resolution: task.resolution,
1139
1168
  durationMs: age,
1169
+ principlesGenerated,
1170
+ });
1171
+
1172
+ // Record task completion in event stats (for timeout path too)
1173
+ eventLog.recordEvolutionTaskCompleted({
1174
+ taskId: task.id,
1175
+ taskType: task.source || 'unknown',
1176
+ reason: task.reason || '',
1140
1177
  });
1141
1178
 
1142
1179
  // Update evolution_tasks table - use task.resolution, not hardcoded value
@@ -1150,7 +1187,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1150
1187
  sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
1151
1188
  taskId: task.id,
1152
1189
  outcome: 'timeout',
1153
- summary: `Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout.`
1190
+ summary: `Task ${task.id} completed ${principlesGenerated} principle(s) generated (${task.resolution}).`
1154
1191
  });
1155
1192
  queueChanged = true;
1156
1193
  }
@@ -1,6 +1,5 @@
1
1
  import type { OpenClawPluginApi } from '../openclaw-sdk.js';
2
2
  import { Type } from '@sinclair/typebox';
3
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
4
3
  import { buildPainFlag } from '../core/pain.js';
5
4
  import { resolveWorkspaceDirFromApi } from '../core/path-resolver.js';
6
5
  import { TrajectoryRegistry } from '../core/trajectory.js';
@@ -179,4 +179,78 @@ describe('EventLog', () => {
179
179
  expect(stats.totalPenaltyScore).toBe(12);
180
180
  });
181
181
  });
182
+
183
+ describe('Evolution and rule stats', () => {
184
+ it('should count evolution_task enqueued events', () => {
185
+ eventLog.recordEvolutionTask({ taskId: 't1', taskType: 'pain_diagnosis', reason: 'test' });
186
+ eventLog.recordEvolutionTask({ taskId: 't2', taskType: 'pain_diagnosis', reason: 'test' });
187
+
188
+ const today = new Date().toISOString().slice(0, 10);
189
+ const stats = eventLog.getDailyStats(today);
190
+
191
+ expect(stats.evolution.tasksEnqueued).toBe(2);
192
+ expect(stats.evolution.tasksCompleted).toBe(0);
193
+ });
194
+
195
+ it('should count evolution_task completed events', () => {
196
+ // First enqueue
197
+ eventLog.recordEvolutionTask({ taskId: 't1', taskType: 'pain_diagnosis', reason: 'test' });
198
+ // Then complete
199
+ eventLog.recordEvolutionTaskCompleted({ taskId: 't1', taskType: 'pain_diagnosis', reason: 'test' });
200
+
201
+ const today = new Date().toISOString().slice(0, 10);
202
+ const stats = eventLog.getDailyStats(today);
203
+
204
+ expect(stats.evolution.tasksEnqueued).toBe(1);
205
+ expect(stats.evolution.tasksCompleted).toBe(1);
206
+ });
207
+
208
+ it('should track rule_match events in rulesMatched', () => {
209
+ eventLog.recordRuleMatch('s1', { ruleId: 'edit-exact-match', layer: 'L2', severity: 0.8, textPreview: 'test' });
210
+ eventLog.recordRuleMatch('s1', { ruleId: 'edit-exact-match', layer: 'L2', severity: 0.8, textPreview: 'test' });
211
+ eventLog.recordRuleMatch('s1', { ruleId: 'path-traversal', layer: 'L1', severity: 0.9, textPreview: 'test2' });
212
+
213
+ const today = new Date().toISOString().slice(0, 10);
214
+ const stats = eventLog.getDailyStats(today);
215
+
216
+ expect(stats.pain.rulesMatched['edit-exact-match']).toBe(2);
217
+ expect(stats.pain.rulesMatched['path-traversal']).toBe(1);
218
+ });
219
+
220
+ it('should track rule_promotion events', () => {
221
+ eventLog.recordRulePromotion({ fingerprint: 'fp1', ruleId: 'r1', phrase: 'test', sampleCount: 5, avgSimilarity: 0.9 });
222
+ eventLog.recordRulePromotion({ fingerprint: 'fp2', ruleId: 'r2', phrase: 'test2', sampleCount: 3, avgSimilarity: 0.8 });
223
+
224
+ const today = new Date().toISOString().slice(0, 10);
225
+ const stats = eventLog.getDailyStats(today);
226
+
227
+ expect(stats.pain.candidatesPromoted).toBe(2);
228
+ expect(stats.evolution.rulesPromoted).toBe(2);
229
+ });
230
+
231
+ it('should track pain signals by source', () => {
232
+ eventLog.recordPainSignal('s1', { source: 'tool_failure', score: 50, reason: 'edit failed' });
233
+ eventLog.recordPainSignal('s2', { source: 'tool_failure', score: 60, reason: 'read failed' });
234
+ eventLog.recordPainSignal('s3', { source: 'user_empathy', score: 10, reason: 'user frustrated' });
235
+
236
+ const today = new Date().toISOString().slice(0, 10);
237
+ const stats = eventLog.getDailyStats(today);
238
+
239
+ expect(stats.pain.signalsBySource['tool_failure']).toBe(2);
240
+ expect(stats.pain.signalsBySource['user_empathy']).toBe(1);
241
+ expect(stats.pain.signalsDetected).toBe(3);
242
+ });
243
+
244
+ it('should calculate avgScore for pain signals', () => {
245
+ eventLog.recordPainSignal('s1', { source: 'tool_failure', score: 50, reason: 'test' });
246
+ eventLog.recordPainSignal('s2', { source: 'tool_failure', score: 70, reason: 'test' });
247
+ eventLog.recordPainSignal('s3', { source: 'tool_failure', score: 60, reason: 'test' });
248
+
249
+ const today = new Date().toISOString().slice(0, 10);
250
+ const stats = eventLog.getDailyStats(today);
251
+
252
+ expect(stats.pain.avgScore).toBe(60); // (50+70+60)/3 = 60
253
+ expect(stats.pain.maxScore).toBe(70);
254
+ });
255
+ });
182
256
  });
@@ -1,8 +1,9 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import * as os from 'os';
4
5
 
5
- const TEST_AGENTS_DIR = path.join('/tmp', 'pd-test-agents-' + Date.now());
6
+ const TEST_AGENTS_DIR = path.join(os.tmpdir(), 'pd-test-agents-' + Date.now());
6
7
 
7
8
  // Set env before module load
8
9
  process.env.PD_TEST_AGENTS_DIR = TEST_AGENTS_DIR;
@@ -1,11 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import * as os from 'os';
4
5
  import { clearPainFlag, PAIN_FLAG_FILENAME } from '../../src/core/pain-lifecycle.js';
5
6
  import { resolvePdPath } from '../../src/core/paths.js';
6
7
 
7
8
  describe('PainLifecycle', () => {
8
- const workspaceDir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'pain-lifecycle-test-'));
9
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pain-lifecycle-test-'));
9
10
  const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
10
11
 
11
12
  beforeEach(() => {
@@ -1,11 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
+ import * as os from 'os';
4
5
  import { clearPainFlag } from '../../src/core/pain-lifecycle.js';
5
6
  import { resolvePdPath } from '../../src/core/paths.js';
6
7
 
7
8
  describe('Pain Lifecycle E2E', () => {
8
- const workspaceDir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'pain-lifecycle-e2e-'));
9
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pain-lifecycle-e2e-'));
9
10
  const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
10
11
  const stateDir = path.dirname(painFlagPath);
11
12
 
@@ -1,22 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { handleEvolveTask } from '../../src/commands/evolver';
3
-
4
- describe('Evolver Synergy Module', () => {
5
- it('should handle /evolve-task command', () => {
6
- const mockCtx = {
7
- workspaceDir: '/mock/workspace',
8
- commandBody: '/evolve-task',
9
- channel: 'cli',
10
- isAuthorizedSender: true,
11
- config: {} as any,
12
- args: 'Fix tests'
13
- };
14
-
15
- const result = handleEvolveTask(mockCtx as any);
16
-
17
- expect(result).toBeDefined();
18
- expect(result.text).toContain('Evolver Handoff Requested');
19
- expect(result.text).toContain('sessions_spawn');
20
- expect(result.text).toContain('Fix tests');
21
- });
22
- });