principles-disciple 1.7.0 → 1.7.1

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.
@@ -6,6 +6,11 @@ const sessions = new Map();
6
6
  let persistDir = null;
7
7
  /** Debounce timer for persistence */
8
8
  let persistTimer = null;
9
+ function logSessionTrackerWarning(message, error) {
10
+ const detail = error instanceof Error ? error.message : error ? String(error) : '';
11
+ const suffix = detail ? `: ${detail}` : '';
12
+ console.warn(`[PD:SessionTracker] ${message}${suffix}`);
13
+ }
9
14
  /**
10
15
  * Initialize persistence for session state.
11
16
  * Call this once during plugin startup.
@@ -48,13 +53,13 @@ function loadAllSessions() {
48
53
  }
49
54
  sessions.set(state.sessionId, state);
50
55
  }
51
- catch {
52
- // Ignore corrupted files
56
+ catch (error) {
57
+ logSessionTrackerWarning(`Failed to load session snapshot ${file}`, error);
53
58
  }
54
59
  }
55
60
  }
56
61
  catch (err) {
57
- // Ignore errors during load
62
+ logSessionTrackerWarning('Failed to load persisted sessions', err);
58
63
  }
59
64
  }
60
65
  /**
@@ -69,8 +74,8 @@ function persistSession(state) {
69
74
  try {
70
75
  fs.writeFileSync(sessionPath, JSON.stringify(state, null, 2), 'utf-8');
71
76
  }
72
- catch {
73
- // Ignore persistence errors
77
+ catch (error) {
78
+ logSessionTrackerWarning(`Failed to persist session ${state.sessionId}`, error);
74
79
  }
75
80
  }
76
81
  /**
@@ -108,6 +113,8 @@ function getOrCreateSession(sessionId, workspaceDir) {
108
113
  cacheHits: 0,
109
114
  stuckLoops: 0,
110
115
  currentGfi: 0,
116
+ gfiBySource: {},
117
+ lastErrorSource: '',
111
118
  lastErrorHash: '',
112
119
  consecutiveErrors: 0,
113
120
  dailyToolCalls: 0,
@@ -124,6 +131,12 @@ function getOrCreateSession(sessionId, workspaceDir) {
124
131
  }
125
132
  return state;
126
133
  }
134
+ function ensureGfiLedger(state) {
135
+ if (!state.gfiBySource || typeof state.gfiBySource !== 'object') {
136
+ state.gfiBySource = {};
137
+ }
138
+ return state.gfiBySource;
139
+ }
127
140
  export function trackToolRead(sessionId, filePath, workspaceDir) {
128
141
  const state = getOrCreateSession(sessionId, workspaceDir);
129
142
  const normalizedPath = path.posix.normalize(filePath.replace(/\\/g, '/'));
@@ -165,12 +178,14 @@ export function trackLlmOutput(sessionId, usage, config, workspaceDir) {
165
178
  /**
166
179
  * Tracks physical friction based on tool execution failures.
167
180
  */
168
- export function trackFriction(sessionId, deltaF, hash, workspaceDir) {
181
+ export function trackFriction(sessionId, deltaF, hash, workspaceDir, options) {
169
182
  const state = getOrCreateSession(sessionId, workspaceDir);
183
+ const ledger = ensureGfiLedger(state);
170
184
  if (hash && hash === state.lastErrorHash) {
171
185
  state.consecutiveErrors++;
172
186
  }
173
187
  else {
188
+ state.lastErrorSource = options?.source || (hash ? `unattributed:${hash}` : 'unattributed:unknown');
174
189
  state.lastErrorHash = hash;
175
190
  state.consecutiveErrors = 1;
176
191
  }
@@ -178,6 +193,8 @@ export function trackFriction(sessionId, deltaF, hash, workspaceDir) {
178
193
  const multiplier = Math.pow(1.5, state.consecutiveErrors - 1);
179
194
  const addedFriction = deltaF * multiplier;
180
195
  state.currentGfi = (state.currentGfi || 0) + addedFriction;
196
+ const sourceKey = options?.source || (hash ? `unattributed:${hash}` : 'unattributed:unknown');
197
+ ledger[sourceKey] = (ledger[sourceKey] || 0) + addedFriction;
181
198
  state.lastActivityAt = Date.now();
182
199
  SystemLogger.log(state.workspaceDir, 'GFI_INC', `Friction added: +${addedFriction.toFixed(1)} (Base: ${deltaF}, Mult: ${multiplier.toFixed(2)}). Total GFI: ${state.currentGfi.toFixed(1)}`);
183
200
  // Update daily stats
@@ -190,12 +207,36 @@ export function trackFriction(sessionId, deltaF, hash, workspaceDir) {
190
207
  /**
191
208
  * Resets the friction index upon successful action.
192
209
  */
193
- export function resetFriction(sessionId, workspaceDir) {
210
+ export function resetFriction(sessionId, workspaceDir, options) {
194
211
  const state = getOrCreateSession(sessionId, workspaceDir);
212
+ const ledger = ensureGfiLedger(state);
213
+ if (options?.source) {
214
+ const sourceKey = options.source;
215
+ const currentSource = ledger[sourceKey] || 0;
216
+ const requestedAmount = Number.isFinite(options.amount) ? Number(options.amount) : currentSource;
217
+ const amountToRemove = Math.max(0, Math.min(currentSource, requestedAmount));
218
+ if (amountToRemove > 0) {
219
+ ledger[sourceKey] = Math.max(0, currentSource - amountToRemove);
220
+ if (ledger[sourceKey] === 0) {
221
+ delete ledger[sourceKey];
222
+ }
223
+ state.currentGfi = Math.max(0, (state.currentGfi || 0) - amountToRemove);
224
+ SystemLogger.log(state.workspaceDir, 'GFI_SLICE_RESET', `Friction slice reset for ${sourceKey}: -${amountToRemove.toFixed(1)}. Total GFI: ${state.currentGfi.toFixed(1)}`);
225
+ if (state.lastErrorSource === sourceKey) {
226
+ state.consecutiveErrors = 0;
227
+ state.lastErrorHash = '';
228
+ state.lastErrorSource = '';
229
+ }
230
+ }
231
+ schedulePersistence(state);
232
+ return state;
233
+ }
195
234
  if (state.currentGfi > 0) {
196
235
  SystemLogger.log(state.workspaceDir, 'GFI_RESET', `Friction reset to 0 (Was: ${state.currentGfi.toFixed(1)}). Action successful.`);
197
236
  }
198
237
  state.currentGfi = 0;
238
+ state.gfiBySource = {};
239
+ state.lastErrorSource = '';
199
240
  state.consecutiveErrors = 0;
200
241
  state.lastErrorHash = '';
201
242
  // Schedule persistence
@@ -248,6 +289,16 @@ export function clearInjectedProbationIds(sessionId, workspaceDir) {
248
289
  export function getSession(sessionId) {
249
290
  return sessions.get(sessionId);
250
291
  }
292
+ export function listSessions(workspaceDir) {
293
+ return [...sessions.values()]
294
+ .filter((state) => !workspaceDir || !state.workspaceDir || state.workspaceDir === workspaceDir)
295
+ .map((state) => ({
296
+ ...state,
297
+ toolReadsByFile: { ...state.toolReadsByFile },
298
+ gfiBySource: state.gfiBySource ? { ...state.gfiBySource } : undefined,
299
+ injectedProbationIds: state.injectedProbationIds ? [...state.injectedProbationIds] : undefined,
300
+ }));
301
+ }
251
302
  export function clearSession(sessionId) {
252
303
  sessions.delete(sessionId);
253
304
  }
@@ -264,8 +315,8 @@ export function garbageCollectSessions() {
264
315
  try {
265
316
  fs.unlinkSync(sessionPath);
266
317
  }
267
- catch {
268
- // Ignore deletion errors
318
+ catch (error) {
319
+ logSessionTrackerWarning(`Failed to delete session snapshot for ${id}`, error);
269
320
  }
270
321
  }
271
322
  }
@@ -17,6 +17,8 @@ export interface TrustScorecard {
17
17
  reason: string;
18
18
  timestamp: string;
19
19
  }>;
20
+ frozen?: boolean;
21
+ reward_policy?: 'frozen_all_positive' | 'frozen_atomic_positive_keep_plan_ready';
20
22
  }
21
23
  export type TrustStage = 1 | 2 | 3 | 4;
22
24
  export declare const EXPLORATORY_TOOLS: string[];
@@ -29,6 +31,7 @@ export declare class TrustEngine {
29
31
  private get config();
30
32
  private get trustSettings();
31
33
  private loadScorecard;
34
+ private applyLegacyFreezeMetadata;
32
35
  private saveScorecard;
33
36
  getScore(): number;
34
37
  getScorecard(): TrustScorecard;
@@ -45,6 +48,7 @@ export declare class TrustEngine {
45
48
  toolName?: string;
46
49
  error?: string;
47
50
  }): void;
51
+ private touchScorecard;
48
52
  private updateScore;
49
53
  resetTrust(newScore?: number): void;
50
54
  getStatusSummary(): {
@@ -29,6 +29,7 @@ export const CONSTRUCTIVE_TOOLS = [
29
29
  'insert', 'patch', 'delete_file', 'move_file', 'run_shell_command',
30
30
  'pd_run_worker', 'sessions_spawn', 'evolve-task', 'init-strategy'
31
31
  ];
32
+ const LEGACY_TRUST_REWARD_POLICY = 'frozen_all_positive';
32
33
  export class TrustEngine {
33
34
  scorecard;
34
35
  workspaceDir;
@@ -69,6 +70,7 @@ export class TrustEngine {
69
70
  data.history = [];
70
71
  if (data.exploratory_failure_streak === undefined)
71
72
  data.exploratory_failure_streak = 0;
73
+ this.applyLegacyFreezeMetadata(data);
72
74
  return data;
73
75
  }
74
76
  catch (e) {
@@ -77,7 +79,7 @@ export class TrustEngine {
77
79
  }
78
80
  const now = new Date();
79
81
  const coldStartEnd = new Date(now.getTime() + settings.cold_start.cold_start_period_ms);
80
- return {
82
+ const scorecard = {
81
83
  trust_score: settings.cold_start.initial_trust,
82
84
  success_streak: 0,
83
85
  failure_streak: 0,
@@ -88,6 +90,12 @@ export class TrustEngine {
88
90
  first_activity_at: now.toISOString(),
89
91
  history: []
90
92
  };
93
+ this.applyLegacyFreezeMetadata(scorecard);
94
+ return scorecard;
95
+ }
96
+ applyLegacyFreezeMetadata(scorecard) {
97
+ scorecard.frozen = true;
98
+ scorecard.reward_policy = LEGACY_TRUST_REWARD_POLICY;
91
99
  }
92
100
  saveScorecard() {
93
101
  const scorecardPath = resolvePdPath(this.workspaceDir, 'AGENT_SCORECARD');
@@ -120,39 +128,19 @@ export class TrustEngine {
120
128
  return new Date() < new Date(this.scorecard.cold_start_end);
121
129
  }
122
130
  recordSuccess(reason, context, isSubagent = false) {
123
- const settings = this.trustSettings;
124
- const rewards = settings.rewards;
125
131
  const toolName = context?.toolName;
126
132
  // 1. Check if this is an exploratory tool success
127
133
  const isExploratory = toolName && EXPLORATORY_TOOLS.includes(toolName);
128
134
  if (reason === 'tool_success' && isExploratory) {
129
- // Exploratory tools don't grant trust points, but they:
130
- // 1. Reset the exploratory failure streak
131
- // 2. Prove the agent isn't stuck (no delta, no success_streak increment)
132
135
  this.scorecard.exploratory_failure_streak = 0;
133
- this.updateScore(0, `Exploratory Success: ${toolName}`, 'info', context);
136
+ this.touchScorecard();
134
137
  return;
135
138
  }
136
- let delta = rewards.success_base;
137
- if (isSubagent) {
138
- delta = rewards.subagent_success;
139
- }
140
- else if (reason === 'tool_success') {
141
- delta = rewards.tool_success_reward ?? 0.2;
142
- }
143
- else if (reason === 'plan_ready') {
144
- delta = 5; // Strategic reward
145
- }
146
- this.scorecard.success_streak++;
139
+ // Phase 1 freeze: do not let atomic successes inflate legacy trust.
140
+ this.scorecard.success_streak = 0;
147
141
  this.scorecard.failure_streak = 0; // Reset failure streak on constructive success
148
142
  this.scorecard.exploratory_failure_streak = 0;
149
- if (this.scorecard.success_streak >= rewards.streak_bonus_threshold) {
150
- delta += rewards.streak_bonus;
151
- }
152
- if (this.scorecard.trust_score < settings.stages.stage_1_observer) {
153
- delta += rewards.recovery_boost;
154
- }
155
- this.updateScore(delta, reason, 'success', context);
143
+ this.touchScorecard();
156
144
  }
157
145
  recordFailure(type, context) {
158
146
  const settings = this.trustSettings;
@@ -204,8 +192,14 @@ export class TrustEngine {
204
192
  delta = penalties.max_penalty;
205
193
  this.updateScore(delta, `Failure: ${toolName || type}`, 'failure', context);
206
194
  }
195
+ touchScorecard() {
196
+ this.applyLegacyFreezeMetadata(this.scorecard);
197
+ this.scorecard.last_updated = new Date().toISOString();
198
+ this.saveScorecard();
199
+ }
207
200
  updateScore(delta, reason, type, context) {
208
201
  const oldScore = this.scorecard.trust_score;
202
+ this.applyLegacyFreezeMetadata(this.scorecard);
209
203
  this.scorecard.trust_score += delta;
210
204
  // Floor score: never drop below 30 (prevents Trust collapse from cascades)
211
205
  if (this.scorecard.trust_score < 30)
@@ -255,6 +249,7 @@ export class TrustEngine {
255
249
  this.scorecard.first_activity_at = now.toISOString();
256
250
  this.scorecard.cold_start_end = coldStartEnd.toISOString();
257
251
  this.scorecard.history.push({ type: 'success', delta: 0, reason: 'Manual trust reset (Spiritual Cleanse)', timestamp: now.toISOString() });
252
+ this.applyLegacyFreezeMetadata(this.scorecard);
258
253
  this.saveScorecard();
259
254
  }
260
255
  getStatusSummary() {
@@ -37,7 +37,7 @@ const BASH_TOOLS_SET = new Set([
37
37
  /**
38
38
  * 分析 Bash 命令风险等级
39
39
  */
40
- function analyzeBashCommand(command, safePatterns, dangerousPatterns) {
40
+ function analyzeBashCommand(command, safePatterns, dangerousPatterns, logger) {
41
41
  let normalizedCmd = command.trim().toLowerCase();
42
42
  // P2 fix: Unicode de-obfuscation — convert Cyrillic/Unicode lookalikes to ASCII equivalents
43
43
  // Common Cyrillic lookalikes that could bypass detection: аеорсух (Cyrillic) → aeopcyx (Latin)
@@ -72,8 +72,10 @@ function analyzeBashCommand(command, safePatterns, dangerousPatterns) {
72
72
  return 'dangerous';
73
73
  }
74
74
  }
75
- catch {
76
- // 忽略无效正则
75
+ catch (error) {
76
+ logger?.warn?.(`[PD_GATE] Invalid dangerous bash regex "${pattern}": ${String(error)}. Failing closed.`);
77
+ return 'dangerous';
78
+ // Fail-closed: 无效的危险模式正则视为匹配危险命令
77
79
  }
78
80
  }
79
81
  }
@@ -87,8 +89,8 @@ function analyzeBashCommand(command, safePatterns, dangerousPatterns) {
87
89
  break;
88
90
  }
89
91
  }
90
- catch {
91
- // ignore
92
+ catch (error) {
93
+ logger?.warn?.(`[PD_GATE] Invalid safe bash regex "${pattern}": ${String(error)}. Ignoring safe override.`);
92
94
  }
93
95
  }
94
96
  if (!isSafe) {
@@ -187,7 +189,7 @@ export function handleBeforeToolCall(event, ctx) {
187
189
  // TIER 3: Bash 命令 - 根据内容判断
188
190
  if (BASH_TOOLS_SET.has(event.toolName)) {
189
191
  const command = String(event.params.command || event.params.args || '');
190
- const bashRisk = analyzeBashCommand(command, gfiGateConfig?.bash_safe_patterns || [], gfiGateConfig?.bash_dangerous_patterns || []);
192
+ const bashRisk = analyzeBashCommand(command, gfiGateConfig?.bash_safe_patterns || [], gfiGateConfig?.bash_dangerous_patterns || [], logger);
191
193
  if (bashRisk === 'dangerous') {
192
194
  // 危险命令 - 直接拦截
193
195
  logger?.warn?.(`[PD:GFI_GATE] Dangerous bash command blocked: ${command.substring(0, 50)}...`);
@@ -378,6 +380,46 @@ GFI: ${currentGfi}/100
378
380
  matchesAnyPattern(relPath, planApprovals.allowed_patterns || []) &&
379
381
  ((planApprovals.max_lines_override ?? -1) === -1 || lineChanges <= (planApprovals.max_lines_override ?? -1)));
380
382
  logger.info(`[PD_GATE] Trust: ${trustScore} (Stage ${stage}), Risk: ${riskLevel}, Path: ${relPath}`);
383
+ // ── EP SIMULATION MODE (M6验证) ──
384
+ // 记录EP系统的模拟决策,但不生效(仅用于对比分析)
385
+ // BUGFIX #90: 移到所有Stage检查之前,确保所有Stage都触发EP simulation记录
386
+ try {
387
+ const epDecision = checkEvolutionGate(ctx.workspaceDir, {
388
+ toolName: event.toolName,
389
+ content: event.params?.content,
390
+ lineCount: lineChanges,
391
+ isRiskPath: risky,
392
+ });
393
+ const epLogEntry = {
394
+ timestamp: new Date().toISOString(),
395
+ toolName: event.toolName,
396
+ filePath: relPath,
397
+ trustEngine: { score: trustScore, stage, decision: 'allow' },
398
+ epSystem: { tier: epDecision.currentTier ?? 'UNKNOWN', allowed: epDecision.allowed, reason: epDecision.reason },
399
+ conflict: epDecision.allowed === false, // Trust允许但EP拒绝(任何阶段)
400
+ };
401
+ const epLogPath = path.join(ctx.workspaceDir, '.state', 'ep_simulation.jsonl');
402
+ // 安全创建目录(如果失败则跳过日志写入,但不影响 Trust Engine 决策)
403
+ let canWriteEpLog = true;
404
+ try {
405
+ fs.mkdirSync(path.dirname(epLogPath), { recursive: true });
406
+ }
407
+ catch (mkdirErr) {
408
+ if (!mkdirErr || mkdirErr.code !== 'EEXIST') {
409
+ logger.warn(`[PD_EP_SIM] Failed to create log dir: ${mkdirErr?.message ?? String(mkdirErr)}, skipping log`);
410
+ canWriteEpLog = false;
411
+ }
412
+ }
413
+ if (canWriteEpLog) {
414
+ fs.appendFileSync(epLogPath, JSON.stringify(epLogEntry) + '\n');
415
+ }
416
+ logger.info(`[PD_EP_SIM] Tier: ${epDecision.currentTier}, Allowed: ${epDecision.allowed}, Trust: ${trustScore} (Stage ${stage})`);
417
+ }
418
+ catch (err) {
419
+ // EP 模拟失败不应该影响 Trust Engine 决策
420
+ const errMsg = err instanceof Error ? err.message : String(err);
421
+ logger.warn(`[PD_EP_SIM] Simulation failed: ${errMsg}, continuing with Trust Engine`);
422
+ }
381
423
  // Stage 1 (Bankruptcy): Block ALL writes to risk paths, and all medium+ writes
382
424
  if (stage === 1) {
383
425
  if (canUsePlanApproval) {
@@ -400,17 +442,17 @@ GFI: ${currentGfi}/100
400
442
  }
401
443
  if (risky || riskLevel !== 'LOW') {
402
444
  // Block if not approved by whitelist
403
- return block(relPath, `Trust score too low (${trustScore}). Stage 1 agents cannot modify risk paths or perform non-trivial edits.`, wctx, event.toolName);
445
+ return block(relPath, `Trust score too low (${trustScore}). Stage 1 agents cannot modify risk paths or perform non-trivial edits.`, wctx, event.toolName, ctx.sessionId);
404
446
  }
405
447
  }
406
448
  // Stage 2 (Editor): Block writes to risk paths. Block large changes.
407
449
  if (stage === 2) {
408
450
  if (risky) {
409
- return block(relPath, `Stage 2 agents are not authorized to modify risk paths.`, wctx, event.toolName);
451
+ return block(relPath, `Stage 2 agents are not authorized to modify risk paths.`, wctx, event.toolName, ctx.sessionId);
410
452
  }
411
453
  const stage2Limit = trustSettings.limits?.stage_2_max_lines ?? 50;
412
454
  if (lineChanges > stage2Limit) {
413
- return block(relPath, `Modification too large (${lineChanges} lines) for Stage 2. Max allowed is ${stage2Limit}.`, wctx, event.toolName);
455
+ return block(relPath, `Modification too large (${lineChanges} lines) for Stage 2. Max allowed is ${stage2Limit}.`, wctx, event.toolName, ctx.sessionId);
414
456
  }
415
457
  }
416
458
  // Stage 3 (Developer): Allow normal writes. Require READY plan for risk paths.
@@ -418,12 +460,12 @@ GFI: ${currentGfi}/100
418
460
  if (risky) {
419
461
  const planStatus = getPlanStatus(ctx.workspaceDir);
420
462
  if (planStatus !== 'READY') {
421
- return block(relPath, `No READY plan found. Stage 3 requires a plan for risk path modifications.`, wctx, event.toolName);
463
+ return block(relPath, `No READY plan found. Stage 3 requires a plan for risk path modifications.`, wctx, event.toolName, ctx.sessionId);
422
464
  }
423
465
  }
424
466
  const stage3Limit = trustSettings.limits?.stage_3_max_lines ?? 300;
425
467
  if (lineChanges > stage3Limit) {
426
- return block(relPath, `Modification too large (${lineChanges} lines) for Stage 3. Max allowed is ${stage3Limit}.`, wctx, event.toolName);
468
+ return block(relPath, `Modification too large (${lineChanges} lines) for Stage 3. Max allowed is ${stage3Limit}.`, wctx, event.toolName, ctx.sessionId);
427
469
  }
428
470
  }
429
471
  // Stage 4 (Architect): Full bypass
@@ -446,52 +488,13 @@ GFI: ${currentGfi}/100
446
488
  }
447
489
  return;
448
490
  }
449
- // ── EP SIMULATION MODE (M6验证) ──
450
- // 记录EP系统的模拟决策,但不生效(仅用于对比分析)
451
- try {
452
- const epDecision = checkEvolutionGate(ctx.workspaceDir, {
453
- toolName: event.toolName,
454
- content: event.params?.content,
455
- lineCount: lineChanges,
456
- isRiskPath: risky,
457
- });
458
- const epLogEntry = {
459
- timestamp: new Date().toISOString(),
460
- toolName: event.toolName,
461
- filePath: relPath,
462
- trustEngine: { score: trustScore, stage, decision: 'allow' },
463
- epSystem: { tier: epDecision.currentTier ?? 'UNKNOWN', allowed: epDecision.allowed, reason: epDecision.reason },
464
- conflict: epDecision.allowed === false, // Trust允许但EP拒绝(任何阶段)
465
- };
466
- const epLogPath = path.join(ctx.workspaceDir, '.state', 'ep_simulation.jsonl');
467
- // 安全创建目录(如果失败则跳过日志写入,但不影响 Trust Engine 决策)
468
- let canWriteEpLog = true;
469
- try {
470
- fs.mkdirSync(path.dirname(epLogPath), { recursive: true });
471
- }
472
- catch (mkdirErr) {
473
- if (!mkdirErr || mkdirErr.code !== 'EEXIST') {
474
- logger.warn(`[PD_EP_SIM] Failed to create log dir: ${mkdirErr?.message ?? String(mkdirErr)}, skipping log`);
475
- canWriteEpLog = false;
476
- }
477
- }
478
- if (canWriteEpLog) {
479
- fs.appendFileSync(epLogPath, JSON.stringify(epLogEntry) + '\n');
480
- }
481
- logger.info(`[PD_EP_SIM] Tier: ${epDecision.currentTier}, Allowed: ${epDecision.allowed}, Trust: ${trustScore} (Stage ${stage})`);
482
- }
483
- catch (err) {
484
- // EP 模拟失败不应该影响 Trust Engine 决策
485
- const errMsg = err instanceof Error ? err.message : String(err);
486
- logger.warn(`[PD_EP_SIM] Simulation failed: ${errMsg}, continuing with Trust Engine`);
487
- }
488
491
  }
489
492
  else {
490
493
  // FALLBACK: Legacy Gate Logic
491
494
  if (risky && profile.gate?.require_plan_for_risk_paths) {
492
495
  const planStatus = getPlanStatus(ctx.workspaceDir);
493
496
  if (planStatus !== 'READY') {
494
- return block(relPath, `No READY plan found in PLAN.md.`, wctx, event.toolName);
497
+ return block(relPath, `No READY plan found in PLAN.md.`, wctx, event.toolName, ctx.sessionId);
495
498
  }
496
499
  }
497
500
  }
@@ -512,10 +515,22 @@ GFI: ${currentGfi}/100
512
515
  }
513
516
  }
514
517
  }
515
- function block(filePath, reason, wctx, toolName) {
518
+ function block(filePath, reason, wctx, toolName, sessionId) {
516
519
  const logger = console;
517
520
  logger.error(`[PD_GATE] BLOCKED: ${filePath}. Reason: ${reason}`);
518
- trackBlock(wctx.workspaceDir);
521
+ if (sessionId) {
522
+ trackBlock(sessionId);
523
+ }
524
+ try {
525
+ wctx.eventLog.recordGateBlock(sessionId, {
526
+ toolName,
527
+ filePath,
528
+ reason,
529
+ });
530
+ }
531
+ catch (error) {
532
+ logger.warn(`[PD_GATE] Failed to record gate block event: ${String(error)}`);
533
+ }
519
534
  return {
520
535
  block: true,
521
536
  blockReason: `[Principles Disciple] Security Gate Blocked this action.\nFile: ${filePath}\nReason: ${reason}\n\nHint: You may need a READY plan or a higher trust score to perform this action.`,
package/dist/hooks/llm.js CHANGED
@@ -237,7 +237,7 @@ export function handleLlmOutput(event, ctx) {
237
237
  const calibratedScore = Math.round(weightedScore * calibrationFactor);
238
238
  const boundedScore = applyRateLimit(ctx.sessionId, event.runId, calibratedScore, config);
239
239
  if (boundedScore > 0) {
240
- trackFriction(ctx.sessionId, boundedScore, `user_empathy_${signal.severity}`, ctx.workspaceDir);
240
+ trackFriction(ctx.sessionId, boundedScore, `user_empathy_${signal.severity}`, ctx.workspaceDir, { source: 'user_empathy' });
241
241
  try {
242
242
  wctx.trajectory?.recordPainEvent?.({
243
243
  sessionId: ctx.sessionId,
@@ -298,7 +298,10 @@ export function handleLlmOutput(event, ctx) {
298
298
  const rolledBackScore = eventLog.rollbackEmpathyEvent(eventId, ctx.sessionId, 'Natural language rollback request detected', 'natural_language');
299
299
  if (rolledBackScore > 0) {
300
300
  // Reset GFI after successful rollback
301
- resetFriction(ctx.sessionId);
301
+ resetFriction(ctx.sessionId, ctx.workspaceDir, {
302
+ source: 'user_empathy',
303
+ amount: rolledBackScore,
304
+ });
302
305
  }
303
306
  }
304
307
  }
@@ -1,6 +1,7 @@
1
1
  import { writePainFlag } from '../core/pain.js';
2
2
  import { WorkspaceContext } from '../core/workspace-context.js';
3
3
  import { empathyObserverManager } from '../service/empathy-observer-manager.js';
4
+ import { acquireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX } from '../service/evolution-worker.js';
4
5
  import * as fs from 'fs';
5
6
  function emitSubagentPainEvent(wctx, payload) {
6
7
  try {
@@ -61,38 +62,49 @@ export async function handleSubagentEnded(event, ctx) {
61
62
  }, true);
62
63
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
63
64
  if (fs.existsSync(queuePath)) {
65
+ const lockPath = queuePath + EVOLUTION_QUEUE_LOCK_SUFFIX;
66
+ const releaseLock = acquireQueueLock(lockPath, console);
67
+ if (!releaseLock) {
68
+ console.warn('[PD:Subagent] Failed to acquire queue lock, skipping queue update');
69
+ return;
70
+ }
64
71
  try {
65
72
  const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
66
73
  let changed = false;
67
- // Find in_progress tasks
68
- const inProgressTasks = queue.filter((t) => t.status === 'in_progress');
69
- if (inProgressTasks.length > 0) {
70
- const resolveTaskTime = (task) => {
71
- const raw = task?.enqueued_at || task?.timestamp;
72
- const ts = new Date(raw).getTime();
73
- return Number.isFinite(ts) ? ts : Number.MAX_SAFE_INTEGER;
74
- };
75
- // Sort by enqueue timestamp (fallback to legacy timestamp) to find the oldest task
76
- const oldestTask = inProgressTasks.sort((a, b) => resolveTaskTime(a) - resolveTaskTime(b))[0];
77
- // Mark as completed
78
- const taskIndex = queue.findIndex((t) => t.id === oldestTask.id);
79
- if (taskIndex !== -1) {
80
- queue[taskIndex].status = 'completed';
81
- queue[taskIndex].completed_at = new Date().toISOString();
82
- changed = true;
83
- // Clean up the .pain_flag if it was queued, to reset the environment
84
- const painFlagPath = wctx.resolve('PAIN_FLAG');
85
- if (fs.existsSync(painFlagPath)) {
86
- try {
87
- const painData = fs.readFileSync(painFlagPath, 'utf8');
88
- if (painData.includes('status: queued')) {
89
- fs.unlinkSync(painFlagPath);
90
- }
91
- }
92
- catch (e) {
93
- console.error(`[PD:Subagent] Failed to cleanup pain flag: ${String(e)}`);
74
+ const resolveTaskTime = (task) => {
75
+ const raw = task?.enqueued_at || task?.timestamp;
76
+ const ts = new Date(raw).getTime();
77
+ return Number.isFinite(ts) ? ts : Number.MAX_SAFE_INTEGER;
78
+ };
79
+ // Resolve the queue entry by its position, not by id. Historical pain
80
+ // records may legitimately share the same id in legacy data.
81
+ let oldestTaskIndex = -1;
82
+ let oldestTaskTime = Number.MAX_SAFE_INTEGER;
83
+ queue.forEach((task, index) => {
84
+ if (task?.status !== 'in_progress')
85
+ return;
86
+ const taskTime = resolveTaskTime(task);
87
+ if (taskTime < oldestTaskTime) {
88
+ oldestTaskTime = taskTime;
89
+ oldestTaskIndex = index;
90
+ }
91
+ });
92
+ if (oldestTaskIndex !== -1) {
93
+ queue[oldestTaskIndex].status = 'completed';
94
+ queue[oldestTaskIndex].completed_at = new Date().toISOString();
95
+ changed = true;
96
+ // Clean up the .pain_flag if it was queued, to reset the environment
97
+ const painFlagPath = wctx.resolve('PAIN_FLAG');
98
+ if (fs.existsSync(painFlagPath)) {
99
+ try {
100
+ const painData = fs.readFileSync(painFlagPath, 'utf8');
101
+ if (painData.includes('status: queued')) {
102
+ fs.unlinkSync(painFlagPath);
94
103
  }
95
104
  }
105
+ catch (e) {
106
+ console.error(`[PD:Subagent] Failed to cleanup pain flag: ${String(e)}`);
107
+ }
96
108
  }
97
109
  }
98
110
  if (changed) {
@@ -102,6 +114,9 @@ export async function handleSubagentEnded(event, ctx) {
102
114
  catch (e) {
103
115
  console.error(`[PD:Subagent] Failed to update evolution queue: ${String(e)}`);
104
116
  }
117
+ finally {
118
+ releaseLock();
119
+ }
105
120
  }
106
121
  }
107
122
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Trajectory Collector - 行为进化引擎 Phase 0 数据收集
3
+ *
4
+ * 收集工具调用和 LLM 输出到 memory/trajectories/ 目录
5
+ * 用于分析工具使用模式、识别原则应用案例、评估行为质量
6
+ */
7
+ import type { PluginHookAfterToolCallEvent, PluginHookToolContext, PluginHookLlmOutputEvent, PluginHookAgentContext, PluginHookBeforeMessageWriteEvent } from '../openclaw-sdk.js';
8
+ /**
9
+ * 工具调用完成后的处理
10
+ * 记录:工具名、参数、结果、错误、执行时间
11
+ */
12
+ export declare function handleAfterToolCall(event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext & {
13
+ workspaceDir?: string;
14
+ }): void;
15
+ /**
16
+ * LLM 输出处理
17
+ * 记录:provider、model、输出长度、token 使用量
18
+ */
19
+ export declare function handleLlmOutput(event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext & {
20
+ workspaceDir?: string;
21
+ }): void;
22
+ /**
23
+ * 消息写入前的处理
24
+ * 记录:用户/助手消息内容
25
+ */
26
+ export declare function handleBeforeMessageWrite(event: PluginHookBeforeMessageWriteEvent, ctx: PluginHookAgentContext & {
27
+ workspaceDir?: string;
28
+ }): void;
29
+ /**
30
+ * 轨迹汇总统计(供 cron 任务调用)
31
+ */
32
+ export declare function computeTrajectoryStats(workspaceDir: string): object;