principles-disciple 1.7.3 → 1.7.5

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.
Files changed (67) hide show
  1. package/dist/commands/evolution-status.js +4 -2
  2. package/dist/commands/focus.js +30 -155
  3. package/dist/constants/diagnostician.d.ts +16 -0
  4. package/dist/constants/diagnostician.js +60 -0
  5. package/dist/constants/tools.d.ts +2 -2
  6. package/dist/constants/tools.js +1 -1
  7. package/dist/core/config.d.ts +23 -0
  8. package/dist/core/config.js +26 -1
  9. package/dist/core/evolution-engine.js +1 -1
  10. package/dist/core/evolution-logger.d.ts +137 -0
  11. package/dist/core/evolution-logger.js +256 -0
  12. package/dist/core/evolution-reducer.d.ts +23 -0
  13. package/dist/core/evolution-reducer.js +73 -29
  14. package/dist/core/evolution-types.d.ts +6 -0
  15. package/dist/core/focus-history.d.ts +145 -0
  16. package/dist/core/focus-history.js +919 -0
  17. package/dist/core/init.js +24 -0
  18. package/dist/core/profile.js +1 -1
  19. package/dist/core/risk-calculator.d.ts +15 -0
  20. package/dist/core/risk-calculator.js +48 -0
  21. package/dist/core/trajectory.d.ts +73 -0
  22. package/dist/core/trajectory.js +206 -0
  23. package/dist/hooks/gate.js +130 -20
  24. package/dist/hooks/lifecycle.js +104 -0
  25. package/dist/hooks/pain.js +31 -0
  26. package/dist/hooks/prompt.js +136 -38
  27. package/dist/hooks/subagent.d.ts +1 -0
  28. package/dist/hooks/subagent.js +200 -18
  29. package/dist/http/principles-console-route.d.ts +7 -0
  30. package/dist/http/principles-console-route.js +301 -1
  31. package/dist/index.js +0 -2
  32. package/dist/service/central-database.d.ts +104 -0
  33. package/dist/service/central-database.js +648 -0
  34. package/dist/service/control-ui-query-service.d.ts +2 -0
  35. package/dist/service/control-ui-query-service.js +4 -0
  36. package/dist/service/empathy-observer-manager.d.ts +8 -0
  37. package/dist/service/empathy-observer-manager.js +40 -0
  38. package/dist/service/evolution-query-service.d.ts +155 -0
  39. package/dist/service/evolution-query-service.js +258 -0
  40. package/dist/service/evolution-worker.d.ts +4 -0
  41. package/dist/service/evolution-worker.js +185 -63
  42. package/dist/service/phase3-input-filter.d.ts +37 -0
  43. package/dist/service/phase3-input-filter.js +106 -0
  44. package/dist/service/runtime-summary-service.d.ts +15 -0
  45. package/dist/service/runtime-summary-service.js +111 -23
  46. package/dist/tools/deep-reflect.js +8 -2
  47. package/dist/utils/subagent-probe.d.ts +34 -0
  48. package/dist/utils/subagent-probe.js +81 -0
  49. package/openclaw.plugin.json +1 -1
  50. package/package.json +6 -4
  51. package/templates/langs/en/core/AGENTS.md +15 -3
  52. package/templates/langs/en/core/BOOTSTRAP.md +24 -1
  53. package/templates/langs/en/core/TOOLS.md +9 -0
  54. package/templates/langs/zh/core/AGENTS.md +15 -3
  55. package/templates/langs/zh/core/BOOTSTRAP.md +24 -1
  56. package/templates/langs/zh/core/TOOLS.md +9 -0
  57. package/templates/langs/zh/skills/pd-auditor/SKILL.md +61 -0
  58. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +287 -0
  59. package/templates/langs/zh/skills/pd-explorer/SKILL.md +65 -0
  60. package/templates/langs/zh/skills/pd-implementer/SKILL.md +68 -0
  61. package/templates/langs/zh/skills/pd-planner/SKILL.md +65 -0
  62. package/templates/langs/zh/skills/pd-reporter/SKILL.md +78 -0
  63. package/templates/langs/zh/skills/pd-reviewer/SKILL.md +66 -0
  64. package/dist/core/agent-loader.d.ts +0 -44
  65. package/dist/core/agent-loader.js +0 -147
  66. package/dist/tools/agent-spawn.d.ts +0 -54
  67. package/dist/tools/agent-spawn.js +0 -445
package/dist/core/init.js CHANGED
@@ -11,6 +11,24 @@ const DEFAULT_PROFILE = {
11
11
  version: "1.0.0",
12
12
  contextInjection: defaultContextConfig
13
13
  };
14
+ const CORE_GUIDANCE_VERSION = 'pd-core-guidance-v2';
15
+ const CORE_GUIDANCE_FILES = new Set(['AGENTS.md', 'TOOLS.md']);
16
+ function getCoreGuidanceVersionMarker() {
17
+ return `pd-core-guidance-version: ${CORE_GUIDANCE_VERSION}`;
18
+ }
19
+ function hasOutdatedCoreGuidance(file, content) {
20
+ if (!CORE_GUIDANCE_FILES.has(file))
21
+ return false;
22
+ if (content.includes(getCoreGuidanceVersionMarker()))
23
+ return false;
24
+ if (content.includes('pd_spawn_agent'))
25
+ return true;
26
+ if (!content.includes('subagents'))
27
+ return true;
28
+ if (!content.includes('sessions_spawn'))
29
+ return true;
30
+ return false;
31
+ }
14
32
  /**
15
33
  * Ensures that the workspace has the necessary template files for Principles Disciple.
16
34
  * This function flattens 'core' templates to the root so OpenClaw can find them.
@@ -45,6 +63,12 @@ export function ensureWorkspaceTemplates(api, workspaceDir, language = 'en') {
45
63
  fs.copyFileSync(srcPath, destPath);
46
64
  api.logger.info(`[PD] Initialized core file: ${file}`);
47
65
  }
66
+ else if (CORE_GUIDANCE_FILES.has(file)) {
67
+ const existingContent = fs.readFileSync(destPath, 'utf8');
68
+ if (hasOutdatedCoreGuidance(file, existingContent)) {
69
+ api.logger.warn(`[PD] Outdated core guidance detected in ${file}. Review the latest template guidance for peer sessions, subagents, and sessions_spawn routing.`);
70
+ }
71
+ }
48
72
  }
49
73
  }
50
74
  // 3. Copy pain memory seed files
@@ -51,7 +51,7 @@ export const PROFILE_DEFAULTS = {
51
51
  thinking_checkpoint: {
52
52
  enabled: false, // Default OFF to avoid blocking new users
53
53
  window_ms: 5 * 60 * 1000, // 5 minute window
54
- high_risk_tools: ['run_shell_command', 'delete_file', 'move_file', 'pd_run_worker'],
54
+ high_risk_tools: ['run_shell_command', 'delete_file', 'move_file'],
55
55
  },
56
56
  custom_guards: [],
57
57
  };
@@ -5,3 +5,18 @@ export interface FileModification {
5
5
  }
6
6
  export declare function estimateLineChanges(modification: FileModification): number;
7
7
  export declare function assessRiskLevel(filePath: string, modification: FileModification, riskPaths: string[]): RiskLevel;
8
+ /**
9
+ * Get the total line count of a target file.
10
+ * @param absoluteFilePath - Absolute path to the file
11
+ * @returns File line count, or null if file doesn't exist or can't be read
12
+ */
13
+ export declare function getTargetFileLineCount(absoluteFilePath: string): number | null;
14
+ /**
15
+ * Calculate the effective line limit based on percentage of target file.
16
+ * @param targetLineCount - Total lines in target file
17
+ * @param percentage - Allowed percentage (0-100)
18
+ * @param minLines - Absolute minimum threshold
19
+ * @param maxLines - Optional upper bound to prevent misconfiguration
20
+ * @returns Maximum allowed lines (at least minLines, at most maxLines if provided)
21
+ */
22
+ export declare function calculatePercentageThreshold(targetLineCount: number, percentage: number, minLines: number, maxLines?: number): number;
@@ -1,3 +1,4 @@
1
+ import * as fs from 'fs';
1
2
  import { isRisky } from '../utils/io.js';
2
3
  export function estimateLineChanges(modification) {
3
4
  const { toolName, params } = modification;
@@ -37,3 +38,50 @@ export function assessRiskLevel(filePath, modification, riskPaths) {
37
38
  return 'LOW';
38
39
  }
39
40
  }
41
+ /**
42
+ * Get the total line count of a target file.
43
+ * @param absoluteFilePath - Absolute path to the file
44
+ * @returns File line count, or null if file doesn't exist or can't be read
45
+ */
46
+ export function getTargetFileLineCount(absoluteFilePath) {
47
+ try {
48
+ if (!fs.existsSync(absoluteFilePath)) {
49
+ return null; // File genuinely doesn't exist
50
+ }
51
+ const stats = fs.statSync(absoluteFilePath);
52
+ if (!stats.isFile()) {
53
+ return null; // Not a regular file (directory, device, etc.)
54
+ }
55
+ const content = fs.readFileSync(absoluteFilePath, 'utf-8');
56
+ return content.split('\n').length;
57
+ }
58
+ catch (e) {
59
+ // Log error before falling back to null - this is intentional for security gates
60
+ const error = e instanceof Error ? e : new Error(String(e));
61
+ const errorCode = e.code;
62
+ console.error(`[PD:RISK_CALC] Failed to read file for line count: ${absoluteFilePath}`, {
63
+ code: errorCode,
64
+ message: error.message,
65
+ });
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Calculate the effective line limit based on percentage of target file.
71
+ * @param targetLineCount - Total lines in target file
72
+ * @param percentage - Allowed percentage (0-100)
73
+ * @param minLines - Absolute minimum threshold
74
+ * @param maxLines - Optional upper bound to prevent misconfiguration
75
+ * @returns Maximum allowed lines (at least minLines, at most maxLines if provided)
76
+ */
77
+ export function calculatePercentageThreshold(targetLineCount, percentage, minLines, maxLines) {
78
+ // Clamp percentage to valid range [0, 100]
79
+ const clampedPercentage = Math.max(0, Math.min(100, percentage));
80
+ const calculated = Math.round(targetLineCount * (clampedPercentage / 100));
81
+ let effectiveLimit = Math.max(calculated, minLines);
82
+ // Apply optional upper bound
83
+ if (maxLines !== undefined && maxLines > 0) {
84
+ effectiveLimit = Math.min(effectiveLimit, maxLines);
85
+ }
86
+ return effectiveLimit;
87
+ }
@@ -89,6 +89,63 @@ export interface TrajectorySessionInput {
89
89
  sessionId: string;
90
90
  startedAt?: string;
91
91
  }
92
+ export interface EvolutionTaskInput {
93
+ taskId: string;
94
+ traceId: string;
95
+ source: string;
96
+ reason?: string | null;
97
+ score?: number;
98
+ status?: string;
99
+ enqueuedAt?: string | null;
100
+ startedAt?: string | null;
101
+ completedAt?: string | null;
102
+ resolution?: string | null;
103
+ createdAt?: string;
104
+ updatedAt?: string;
105
+ }
106
+ export interface EvolutionEventInput {
107
+ traceId: string;
108
+ taskId?: string | null;
109
+ stage: string;
110
+ level?: string;
111
+ message: string;
112
+ summary?: string | null;
113
+ metadata?: unknown;
114
+ createdAt?: string;
115
+ }
116
+ export interface EvolutionTaskRecord {
117
+ id: number;
118
+ taskId: string;
119
+ traceId: string;
120
+ source: string;
121
+ reason: string | null;
122
+ score: number;
123
+ status: string;
124
+ enqueuedAt: string | null;
125
+ startedAt: string | null;
126
+ completedAt: string | null;
127
+ resolution: string | null;
128
+ createdAt: string;
129
+ updatedAt: string;
130
+ }
131
+ export interface EvolutionEventRecord {
132
+ id: number;
133
+ traceId: string;
134
+ taskId: string | null;
135
+ stage: string;
136
+ level: string;
137
+ message: string;
138
+ summary: string | null;
139
+ metadata: Record<string, unknown>;
140
+ createdAt: string;
141
+ }
142
+ export interface EvolutionTaskFilters {
143
+ status?: string;
144
+ dateFrom?: string;
145
+ dateTo?: string;
146
+ limit?: number;
147
+ offset?: number;
148
+ }
92
149
  export interface AssistantTurnRecord {
93
150
  id: number;
94
151
  sessionId: string;
@@ -145,6 +202,22 @@ export declare class TrajectoryDatabase {
145
202
  recordTrustChange(input: TrajectoryTrustChangeInput): void;
146
203
  recordPrincipleEvent(input: TrajectoryPrincipleEventInput): void;
147
204
  recordTaskOutcome(input: TrajectoryTaskOutcomeInput): void;
205
+ recordEvolutionTask(input: EvolutionTaskInput): void;
206
+ updateEvolutionTask(taskId: string, updates: Partial<Omit<EvolutionTaskInput, 'taskId' | 'traceId' | 'source'>>): void;
207
+ recordEvolutionEvent(input: EvolutionEventInput): void;
208
+ listEvolutionTasks(filters?: EvolutionTaskFilters): EvolutionTaskRecord[];
209
+ listEvolutionEvents(traceId?: string, filters?: {
210
+ limit?: number;
211
+ offset?: number;
212
+ }): EvolutionEventRecord[];
213
+ getEvolutionTaskByTraceId(traceId: string): EvolutionTaskRecord | null;
214
+ getEvolutionStats(): {
215
+ total: number;
216
+ pending: number;
217
+ inProgress: number;
218
+ completed: number;
219
+ failed: number;
220
+ };
148
221
  listAssistantTurns(sessionId: string): AssistantTurnRecord[];
149
222
  listCorrectionSamples(status?: CorrectionSampleReviewStatus): CorrectionSampleRecord[];
150
223
  reviewCorrectionSample(sampleId: string, status: Exclude<CorrectionSampleReviewStatus, 'pending'>, note?: string): CorrectionSampleRecord;
@@ -161,6 +161,181 @@ export class TrajectoryDatabase {
161
161
  `).run(input.sessionId, input.taskId ?? null, input.outcome, input.summary ?? null, safeJson(input.principleIdsJson), input.createdAt ?? nowIso());
162
162
  });
163
163
  }
164
+ recordEvolutionTask(input) {
165
+ const now = nowIso();
166
+ this.withWrite(() => {
167
+ this.db.prepare(`
168
+ INSERT INTO evolution_tasks (
169
+ task_id, trace_id, source, reason, score, status,
170
+ enqueued_at, started_at, completed_at, resolution, created_at, updated_at
171
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
172
+ ON CONFLICT(task_id) DO UPDATE SET
173
+ status = excluded.status,
174
+ started_at = excluded.started_at,
175
+ completed_at = excluded.completed_at,
176
+ resolution = excluded.resolution,
177
+ updated_at = excluded.updated_at
178
+ `).run(input.taskId, input.traceId, input.source, input.reason ?? null, input.score ?? 0, input.status ?? 'pending', input.enqueuedAt ?? null, input.startedAt ?? null, input.completedAt ?? null, input.resolution ?? null, input.createdAt ?? now, input.updatedAt ?? now);
179
+ });
180
+ }
181
+ updateEvolutionTask(taskId, updates) {
182
+ const now = nowIso();
183
+ this.withWrite(() => {
184
+ const setClauses = ['updated_at = ?'];
185
+ const values = [now];
186
+ if (updates.status !== undefined) {
187
+ setClauses.push('status = ?');
188
+ values.push(updates.status);
189
+ }
190
+ if (updates.startedAt !== undefined) {
191
+ setClauses.push('started_at = ?');
192
+ values.push(updates.startedAt);
193
+ }
194
+ if (updates.completedAt !== undefined) {
195
+ setClauses.push('completed_at = ?');
196
+ values.push(updates.completedAt);
197
+ }
198
+ if (updates.resolution !== undefined) {
199
+ setClauses.push('resolution = ?');
200
+ values.push(updates.resolution);
201
+ }
202
+ if (updates.score !== undefined) {
203
+ setClauses.push('score = ?');
204
+ values.push(updates.score);
205
+ }
206
+ values.push(taskId);
207
+ this.db.prepare(`
208
+ UPDATE evolution_tasks SET ${setClauses.join(', ')} WHERE task_id = ?
209
+ `).run(...values);
210
+ });
211
+ }
212
+ recordEvolutionEvent(input) {
213
+ this.withWrite(() => {
214
+ this.db.prepare(`
215
+ INSERT INTO evolution_events (trace_id, task_id, stage, level, message, summary, metadata_json, created_at)
216
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
217
+ `).run(input.traceId, input.taskId ?? null, input.stage, input.level ?? 'info', input.message, input.summary ?? null, safeJson(input.metadata), input.createdAt ?? nowIso());
218
+ });
219
+ }
220
+ listEvolutionTasks(filters = {}) {
221
+ const conditions = [];
222
+ const values = [];
223
+ if (filters.status) {
224
+ conditions.push('status = ?');
225
+ values.push(filters.status);
226
+ }
227
+ if (filters.dateFrom) {
228
+ conditions.push('created_at >= ?');
229
+ values.push(filters.dateFrom);
230
+ }
231
+ if (filters.dateTo) {
232
+ conditions.push('created_at <= ?');
233
+ values.push(filters.dateTo);
234
+ }
235
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
236
+ const limit = filters.limit ?? 50;
237
+ const offset = filters.offset ?? 0;
238
+ const rows = this.db.prepare(`
239
+ SELECT id, task_id, trace_id, source, reason, score, status,
240
+ enqueued_at, started_at, completed_at, resolution, created_at, updated_at
241
+ FROM evolution_tasks
242
+ ${whereClause}
243
+ ORDER BY created_at DESC
244
+ LIMIT ? OFFSET ?
245
+ `).all(...values, limit, offset);
246
+ return rows.map((row) => ({
247
+ id: Number(row.id),
248
+ taskId: String(row.task_id),
249
+ traceId: String(row.trace_id),
250
+ source: String(row.source),
251
+ reason: row.reason ? String(row.reason) : null,
252
+ score: Number(row.score ?? 0),
253
+ status: String(row.status),
254
+ enqueuedAt: row.enqueued_at ? String(row.enqueued_at) : null,
255
+ startedAt: row.started_at ? String(row.started_at) : null,
256
+ completedAt: row.completed_at ? String(row.completed_at) : null,
257
+ resolution: row.resolution ? String(row.resolution) : null,
258
+ createdAt: String(row.created_at),
259
+ updatedAt: String(row.updated_at),
260
+ }));
261
+ }
262
+ listEvolutionEvents(traceId, filters = {}) {
263
+ const limit = filters.limit ?? 100;
264
+ const offset = filters.offset ?? 0;
265
+ let rows;
266
+ if (traceId) {
267
+ rows = this.db.prepare(`
268
+ SELECT id, trace_id, task_id, stage, level, message, summary, metadata_json, created_at
269
+ FROM evolution_events
270
+ WHERE trace_id = ?
271
+ ORDER BY created_at ASC
272
+ LIMIT ? OFFSET ?
273
+ `).all(traceId, limit, offset);
274
+ }
275
+ else {
276
+ rows = this.db.prepare(`
277
+ SELECT id, trace_id, task_id, stage, level, message, summary, metadata_json, created_at
278
+ FROM evolution_events
279
+ ORDER BY created_at DESC
280
+ LIMIT ? OFFSET ?
281
+ `).all(limit, offset);
282
+ }
283
+ return rows.map((row) => ({
284
+ id: Number(row.id),
285
+ traceId: String(row.trace_id),
286
+ taskId: row.task_id ? String(row.task_id) : null,
287
+ stage: String(row.stage),
288
+ level: String(row.level ?? 'info'),
289
+ message: String(row.message),
290
+ summary: row.summary ? String(row.summary) : null,
291
+ metadata: JSON.parse(String(row.metadata_json ?? '{}')),
292
+ createdAt: String(row.created_at),
293
+ }));
294
+ }
295
+ getEvolutionTaskByTraceId(traceId) {
296
+ const row = this.db.prepare(`
297
+ SELECT id, task_id, trace_id, source, reason, score, status,
298
+ enqueued_at, started_at, completed_at, resolution, created_at, updated_at
299
+ FROM evolution_tasks
300
+ WHERE trace_id = ?
301
+ LIMIT 1
302
+ `).get(traceId);
303
+ if (!row)
304
+ return null;
305
+ return {
306
+ id: Number(row.id),
307
+ taskId: String(row.task_id),
308
+ traceId: String(row.trace_id),
309
+ source: String(row.source),
310
+ reason: row.reason ? String(row.reason) : null,
311
+ score: Number(row.score ?? 0),
312
+ status: String(row.status),
313
+ enqueuedAt: row.enqueued_at ? String(row.enqueued_at) : null,
314
+ startedAt: row.started_at ? String(row.started_at) : null,
315
+ completedAt: row.completed_at ? String(row.completed_at) : null,
316
+ resolution: row.resolution ? String(row.resolution) : null,
317
+ createdAt: String(row.created_at),
318
+ updatedAt: String(row.updated_at),
319
+ };
320
+ }
321
+ getEvolutionStats() {
322
+ const rows = this.db.prepare(`
323
+ SELECT status, COUNT(*) as count FROM evolution_tasks GROUP BY status
324
+ `).all();
325
+ const stats = { total: 0, pending: 0, inProgress: 0, completed: 0, failed: 0 };
326
+ for (const row of rows) {
327
+ stats.total += row.count;
328
+ if (row.status === 'pending')
329
+ stats.pending = row.count;
330
+ else if (row.status === 'in_progress')
331
+ stats.inProgress = row.count;
332
+ else if (row.status === 'completed')
333
+ stats.completed = row.count;
334
+ else if (row.status === 'failed')
335
+ stats.failed = row.count;
336
+ }
337
+ return stats;
338
+ }
164
339
  listAssistantTurns(sessionId) {
165
340
  const rows = this.db.prepare(`
166
341
  SELECT id, session_id, run_id, provider, model, raw_text, sanitized_text, blob_ref, created_at
@@ -457,6 +632,32 @@ export class TrajectoryDatabase {
457
632
  row_count INTEGER NOT NULL,
458
633
  created_at TEXT NOT NULL
459
634
  );
635
+ CREATE TABLE IF NOT EXISTS evolution_tasks (
636
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
637
+ task_id TEXT UNIQUE NOT NULL,
638
+ trace_id TEXT NOT NULL,
639
+ source TEXT NOT NULL,
640
+ reason TEXT,
641
+ score INTEGER DEFAULT 0,
642
+ status TEXT DEFAULT 'pending',
643
+ enqueued_at TEXT,
644
+ started_at TEXT,
645
+ completed_at TEXT,
646
+ resolution TEXT,
647
+ created_at TEXT NOT NULL,
648
+ updated_at TEXT NOT NULL
649
+ );
650
+ CREATE TABLE IF NOT EXISTS evolution_events (
651
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
652
+ trace_id TEXT NOT NULL,
653
+ task_id TEXT,
654
+ stage TEXT NOT NULL,
655
+ level TEXT DEFAULT 'info',
656
+ message TEXT NOT NULL,
657
+ summary TEXT,
658
+ metadata_json TEXT,
659
+ created_at TEXT NOT NULL
660
+ );
460
661
  CREATE VIEW IF NOT EXISTS v_error_clusters AS
461
662
  SELECT tool_name, COALESCE(error_type, 'unknown') AS error_type, COUNT(*) AS occurrences
462
663
  FROM tool_calls
@@ -480,6 +681,11 @@ export class TrajectoryDatabase {
480
681
  CREATE INDEX IF NOT EXISTS idx_tool_calls_created_at ON tool_calls(created_at);
481
682
  CREATE INDEX IF NOT EXISTS idx_pain_events_session_id ON pain_events(session_id);
482
683
  CREATE INDEX IF NOT EXISTS idx_correction_samples_review_status ON correction_samples(review_status);
684
+ CREATE INDEX IF NOT EXISTS idx_evolution_tasks_trace_id ON evolution_tasks(trace_id);
685
+ CREATE INDEX IF NOT EXISTS idx_evolution_tasks_status ON evolution_tasks(status);
686
+ CREATE INDEX IF NOT EXISTS idx_evolution_tasks_created_at ON evolution_tasks(created_at);
687
+ CREATE INDEX IF NOT EXISTS idx_evolution_events_trace_id ON evolution_events(trace_id);
688
+ CREATE INDEX IF NOT EXISTS idx_evolution_events_created_at ON evolution_events(created_at);
483
689
  `);
484
690
  const row = this.db.prepare('SELECT version FROM schema_version LIMIT 1').get();
485
691
  this.migrateSchema(row?.version);
@@ -4,16 +4,18 @@ import { isRisky, normalizePath, planStatus as getPlanStatus } from '../utils/io
4
4
  import { matchesAnyPattern } from '../utils/glob-match.js';
5
5
  import { normalizeProfile } from '../core/profile.js';
6
6
  import { trackBlock, hasRecentThinking, getSession } from '../core/session-tracker.js';
7
- import { assessRiskLevel, estimateLineChanges } from '../core/risk-calculator.js';
7
+ import { assessRiskLevel, estimateLineChanges, getTargetFileLineCount, calculatePercentageThreshold } from '../core/risk-calculator.js';
8
8
  import { WorkspaceContext } from '../core/workspace-context.js';
9
9
  import { checkEvolutionGate } from '../core/evolution-engine.js';
10
10
  import { EventLogService } from '../core/event-log.js';
11
11
  import { AGENT_TOOLS, BASH_TOOLS_SET, HIGH_RISK_TOOLS, LOW_RISK_WRITE_TOOLS, WRITE_TOOLS, } from '../constants/tools.js';
12
+ const TRAJECTORY_GATE_BLOCK_RETRY_DELAY_MS = 250;
13
+ const TRAJECTORY_GATE_BLOCK_MAX_RETRIES = 3;
12
14
  // ═══ GFI Gate Tool Tiers ═══
13
15
  // TIER 0: 只读工具 - 永不拦截
14
16
  // TIER 1: 低风险修改 - GFI >= low_risk_block 时拦截
15
- // 注意:pd_run_worker、sessions_spawn、task 是 Agent 派生工具,不应被 GFI Gate 拦截
16
- // 它们属于 AGENT_TOOLS,在早期过滤后直接放行
17
+ // 注意:sessions_spawn、task 是 Agent 派生工具,常规阈值不拦截
18
+ // 但极高 GFI (>=90) 时仍会拦截,防止极端情况下失控
17
19
  // TIER 2: 高风险操作 - GFI >= high_risk_block 时拦截
18
20
  // TIER 3: Bash 命令 - 根据内容判断
19
21
  /**
@@ -132,7 +134,7 @@ export function handleBeforeToolCall(event, ctx) {
132
134
  thinking_checkpoint: {
133
135
  enabled: false, // Default OFF
134
136
  window_ms: 5 * 60 * 1000,
135
- high_risk_tools: ['run_shell_command', 'delete_file', 'move_file', 'pd_run_worker'],
137
+ high_risk_tools: ['run_shell_command', 'delete_file', 'move_file'],
136
138
  }
137
139
  };
138
140
  if (fs.existsSync(profilePath)) {
@@ -347,7 +349,13 @@ GFI: ${currentGfi}/100
347
349
  const trustScore = trustEngine.getScore();
348
350
  const stage = trustEngine.getStage();
349
351
  const trustSettings = wctx.config.get('trust') || {
350
- limits: { stage_2_max_lines: 50, stage_3_max_lines: 300 }
352
+ limits: {
353
+ stage_2_max_lines: 50,
354
+ stage_3_max_lines: 300,
355
+ stage_2_max_percentage: 10,
356
+ stage_3_max_percentage: 15,
357
+ min_lines_fallback: 20,
358
+ }
351
359
  };
352
360
  const riskLevel = assessRiskLevel(relPath, { toolName: event.toolName, params: event.params }, profile.risk_paths);
353
361
  const lineChanges = estimateLineChanges({ toolName: event.toolName, params: event.params });
@@ -421,17 +429,34 @@ GFI: ${currentGfi}/100
421
429
  }
422
430
  if (risky || riskLevel !== 'LOW') {
423
431
  // Block if not approved by whitelist
424
- 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);
432
+ return block(relPath, `Trust score too low (${trustScore}). Stage 1 agents cannot modify risk paths or perform non-trivial edits.`, wctx, event.toolName, logger, ctx.sessionId);
425
433
  }
426
434
  }
427
435
  // Stage 2 (Editor): Block writes to risk paths. Block large changes.
428
436
  if (stage === 2) {
429
437
  if (risky) {
430
- return block(relPath, `Stage 2 agents are not authorized to modify risk paths.`, wctx, event.toolName, ctx.sessionId);
438
+ return block(relPath, `Stage 2 agents are not authorized to modify risk paths.`, wctx, event.toolName, logger, ctx.sessionId);
439
+ }
440
+ // Percentage-based threshold calculation
441
+ const targetAbsolutePath = typeof filePath === 'string' ? path.join(ctx.workspaceDir, filePath) : null;
442
+ const targetLineCount = targetAbsolutePath ? getTargetFileLineCount(targetAbsolutePath) : null;
443
+ const minLinesFallback = trustSettings.limits?.min_lines_fallback ?? 20;
444
+ const stage2MaxPercentage = trustSettings.limits?.stage_2_max_percentage ?? 10;
445
+ const stage2FixedLimit = trustSettings.limits?.stage_2_max_lines ?? 50;
446
+ let effectiveLimit;
447
+ let limitType;
448
+ let actualPercentage = null;
449
+ if (targetLineCount !== null && targetLineCount > 0) {
450
+ effectiveLimit = calculatePercentageThreshold(targetLineCount, stage2MaxPercentage, minLinesFallback);
451
+ actualPercentage = Math.round((lineChanges / targetLineCount) * 100);
452
+ limitType = 'percentage';
453
+ }
454
+ else {
455
+ effectiveLimit = stage2FixedLimit;
456
+ limitType = 'fixed';
431
457
  }
432
- const stage2Limit = trustSettings.limits?.stage_2_max_lines ?? 50;
433
- if (lineChanges > stage2Limit) {
434
- return block(relPath, `Modification too large (${lineChanges} lines) for Stage 2. Max allowed is ${stage2Limit}.`, wctx, event.toolName, ctx.sessionId);
458
+ if (lineChanges > effectiveLimit) {
459
+ return block(relPath, buildLineLimitReason(lineChanges, effectiveLimit, limitType, targetLineCount, actualPercentage, 2), wctx, event.toolName, logger, ctx.sessionId);
435
460
  }
436
461
  }
437
462
  // Stage 3 (Developer): Allow normal writes. Require READY plan for risk paths.
@@ -439,12 +464,29 @@ GFI: ${currentGfi}/100
439
464
  if (risky) {
440
465
  const planStatus = getPlanStatus(ctx.workspaceDir);
441
466
  if (planStatus !== 'READY') {
442
- return block(relPath, `No READY plan found. Stage 3 requires a plan for risk path modifications.`, wctx, event.toolName, ctx.sessionId);
467
+ return block(relPath, `No READY plan found. Stage 3 requires a plan for risk path modifications.`, wctx, event.toolName, logger, ctx.sessionId);
443
468
  }
444
469
  }
445
- const stage3Limit = trustSettings.limits?.stage_3_max_lines ?? 300;
446
- if (lineChanges > stage3Limit) {
447
- return block(relPath, `Modification too large (${lineChanges} lines) for Stage 3. Max allowed is ${stage3Limit}.`, wctx, event.toolName, ctx.sessionId);
470
+ // Percentage-based threshold calculation
471
+ const targetAbsolutePath = typeof filePath === 'string' ? path.join(ctx.workspaceDir, filePath) : null;
472
+ const targetLineCount = targetAbsolutePath ? getTargetFileLineCount(targetAbsolutePath) : null;
473
+ const minLinesFallback = trustSettings.limits?.min_lines_fallback ?? 20;
474
+ const stage3MaxPercentage = trustSettings.limits?.stage_3_max_percentage ?? 15;
475
+ const stage3FixedLimit = trustSettings.limits?.stage_3_max_lines ?? 300;
476
+ let effectiveLimit;
477
+ let limitType;
478
+ let actualPercentage = null;
479
+ if (targetLineCount !== null && targetLineCount > 0) {
480
+ effectiveLimit = calculatePercentageThreshold(targetLineCount, stage3MaxPercentage, minLinesFallback);
481
+ actualPercentage = Math.round((lineChanges / targetLineCount) * 100);
482
+ limitType = 'percentage';
483
+ }
484
+ else {
485
+ effectiveLimit = stage3FixedLimit;
486
+ limitType = 'fixed';
487
+ }
488
+ if (lineChanges > effectiveLimit) {
489
+ return block(relPath, buildLineLimitReason(lineChanges, effectiveLimit, limitType, targetLineCount, actualPercentage, 3), wctx, event.toolName, logger, ctx.sessionId);
448
490
  }
449
491
  }
450
492
  // Stage 4 (Architect): Full bypass
@@ -473,7 +515,7 @@ GFI: ${currentGfi}/100
473
515
  if (risky && profile.gate?.require_plan_for_risk_paths) {
474
516
  const planStatus = getPlanStatus(ctx.workspaceDir);
475
517
  if (planStatus !== 'READY') {
476
- return block(relPath, `No READY plan found in PLAN.md.`, wctx, event.toolName, ctx.sessionId);
518
+ return block(relPath, `No READY plan found in PLAN.md.`, wctx, event.toolName, logger, ctx.sessionId);
477
519
  }
478
520
  }
479
521
  }
@@ -494,12 +536,32 @@ GFI: ${currentGfi}/100
494
536
  }
495
537
  }
496
538
  }
497
- function block(filePath, reason, wctx, toolName, sessionId) {
498
- const logger = console;
499
- logger.error(`[PD_GATE] BLOCKED: ${filePath}. Reason: ${reason}`);
539
+ /**
540
+ * Build a detailed reason message for line limit blocks.
541
+ */
542
+ function buildLineLimitReason(lineChanges, effectiveLimit, limitType, targetLineCount, actualPercentage, stage) {
543
+ if (limitType === 'percentage' && targetLineCount !== null && actualPercentage !== null) {
544
+ return `Modification too large: ${lineChanges} lines (${actualPercentage}% of ${targetLineCount} lines). ` +
545
+ `Stage ${stage} limit is ${effectiveLimit} lines (${limitType}). ` +
546
+ `Threshold calculation: min(${targetLineCount} × ${actualPercentage}%, ${effectiveLimit} lines).`;
547
+ }
548
+ else {
549
+ return `Modification too large: ${lineChanges} lines. ` +
550
+ `Stage ${stage} limit is ${effectiveLimit} lines (fixed threshold). ` +
551
+ `Note: Could not read target file to calculate percentage-based limit. Check file permissions and encoding.`;
552
+ }
553
+ }
554
+ function block(filePath, reason, wctx, toolName, logger, sessionId) {
555
+ logger.error?.(`[PD_GATE] BLOCKED: ${filePath}. Reason: ${reason}`);
500
556
  if (sessionId) {
501
557
  trackBlock(sessionId);
502
558
  }
559
+ const trajectoryPayload = {
560
+ sessionId: sessionId ?? null,
561
+ toolName,
562
+ filePath,
563
+ reason,
564
+ };
503
565
  try {
504
566
  wctx.eventLog.recordGateBlock(sessionId, {
505
567
  toolName,
@@ -508,13 +570,61 @@ function block(filePath, reason, wctx, toolName, sessionId) {
508
570
  });
509
571
  }
510
572
  catch (error) {
511
- logger.warn(`[PD_GATE] Failed to record gate block event: ${String(error)}`);
573
+ logger.warn?.(`[PD_GATE] Failed to record gate block event: ${String(error)}`);
574
+ }
575
+ try {
576
+ wctx.trajectory?.recordGateBlock?.(trajectoryPayload);
577
+ }
578
+ catch (error) {
579
+ logger.warn?.(`[PD_GATE] Failed to record trajectory gate block: ${String(error)}`);
580
+ scheduleTrajectoryGateBlockRetry(wctx, trajectoryPayload, 1, {
581
+ warn: (message) => logger.warn?.(message),
582
+ error: (message) => logger.error?.(message),
583
+ });
512
584
  }
513
585
  return {
514
586
  block: true,
515
- 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.`,
587
+ blockReason: `[Principles Disciple] Security Gate Blocked this action.
588
+ File: ${filePath}
589
+ Reason: ${reason}
590
+
591
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
592
+ 📋 How to unblock this operation:
593
+
594
+ 1. Use the plan-script skill to create a PLAN.md:
595
+ → Invoke: skill:plan-script
596
+
597
+ 2. Fill in the plan with:
598
+ - Target Files: ${filePath}
599
+ - Steps: What you want to do (be specific)
600
+ - Metrics: How to verify success
601
+ - Active Mental Models: Select 2 relevant models from .principles/THINKING_OS.md
602
+ - Rollback: How to restore if it fails
603
+
604
+ 3. After completing the plan, set STATUS: READY in PLAN.md
605
+
606
+ 4. Retry the operation
607
+
608
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
609
+ This is a mandatory security gate. The operation was blocked because the modification exceeds the allowed threshold for your current trust stage.
610
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
516
611
  };
517
612
  }
613
+ function scheduleTrajectoryGateBlockRetry(wctx, payload, attempt, logger) {
614
+ if (attempt > TRAJECTORY_GATE_BLOCK_MAX_RETRIES) {
615
+ logger.error?.(`[PD_GATE] Failed to persist trajectory gate block after ${TRAJECTORY_GATE_BLOCK_MAX_RETRIES} retries: ${payload.toolName} ${payload.filePath}`);
616
+ return;
617
+ }
618
+ setTimeout(() => {
619
+ try {
620
+ wctx.trajectory?.recordGateBlock?.(payload);
621
+ }
622
+ catch (error) {
623
+ logger.warn(`[PD_GATE] Retrying trajectory gate block persistence: ${String(error)}`);
624
+ scheduleTrajectoryGateBlockRetry(wctx, payload, attempt + 1, logger);
625
+ }
626
+ }, TRAJECTORY_GATE_BLOCK_RETRY_DELAY_MS);
627
+ }
518
628
  // ═══════════════════════════════════════════════════════════════
519
629
  // P-03: Edit Tool Force Verification
520
630
  // ═══════════════════════════════════════════════════════════════