principles-disciple 1.40.0 → 1.42.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.
- package/.planning/codebase/ARCHITECTURE.md +157 -0
- package/.planning/codebase/CONCERNS.md +145 -0
- package/.planning/codebase/CONVENTIONS.md +148 -0
- package/.planning/codebase/INTEGRATIONS.md +81 -0
- package/.planning/codebase/STACK.md +87 -0
- package/.planning/codebase/STRUCTURE.md +193 -0
- package/.planning/codebase/TESTING.md +243 -0
- package/esbuild.config.js +32 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/compile-principles.mjs +94 -0
- package/scripts/sync-plugin.mjs +96 -281
- package/src/commands/pain.ts +12 -5
- package/src/commands/promote-impl.ts +13 -7
- package/src/commands/rollback.ts +10 -3
- package/src/core/event-log.ts +8 -6
- package/src/core/evolution-types.ts +33 -1
- package/src/core/principle-compiler/code-validator.ts +120 -0
- package/src/core/principle-compiler/compiler.ts +242 -0
- package/src/core/principle-compiler/index.ts +10 -0
- package/src/core/principle-compiler/ledger-registrar.ts +107 -0
- package/src/core/principle-compiler/template-generator.ts +108 -0
- package/src/core/reflection/reflection-context.ts +228 -0
- package/src/hooks/message-sanitize.ts +18 -5
- package/src/hooks/prompt.ts +15 -4
- package/src/hooks/subagent.ts +2 -3
- package/src/http/principles-console-route.ts +21 -4
- package/src/service/evolution-worker.ts +89 -365
- package/src/service/queue-io.ts +375 -0
- package/src/service/queue-migration.ts +122 -0
- package/src/service/sleep-cycle.ts +157 -0
- package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
- package/src/service/workflow-watchdog.ts +168 -0
- package/src/tools/deep-reflect.ts +22 -11
- package/src/types/event-payload.ts +80 -0
- package/src/types/queue.ts +70 -0
- package/src/utils/file-lock.ts +2 -2
- package/src/utils/io.ts +11 -3
- package/tests/core/code-validator.test.ts +197 -0
- package/tests/core/evolution-migration.test.ts +325 -1
- package/tests/core/ledger-registrar.test.ts +232 -0
- package/tests/core/principle-compiler.test.ts +348 -0
- package/tests/core/queue-purge.test.ts +337 -0
- package/tests/core/reflection-context.test.ts +356 -0
- package/tests/core/template-generator.test.ts +101 -0
- package/tests/fixtures/legacy-queue-v1.json +74 -0
- package/tests/integration/principle-compiler-e2e.test.ts +335 -0
- package/tests/queue/async-lock.test.ts +200 -0
- package/tests/service/evolution-worker.queue.test.ts +296 -0
- package/tests/service/queue-io.test.ts +229 -0
- package/tests/service/queue-migration.test.ts +147 -0
- package/tests/service/workflow-watchdog.test.ts +372 -0
package/src/commands/pain.ts
CHANGED
|
@@ -80,6 +80,14 @@ function formatEmpathyCard(stats: EmpathyEventStats, range: string, isZh: boolea
|
|
|
80
80
|
return lines.join('\n');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Extended context interface that includes sessionId injected by the plugin framework.
|
|
85
|
+
* PluginCommandContext does not include sessionId in its type definition.
|
|
86
|
+
*/
|
|
87
|
+
interface SessionAwareCommandContext extends PluginCommandContext {
|
|
88
|
+
sessionId: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
/**
|
|
84
92
|
* Handles the /pd-status command
|
|
85
93
|
*/
|
|
@@ -89,15 +97,14 @@ export function handlePainCommand(ctx: PluginCommandContext): PluginCommandResul
|
|
|
89
97
|
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
|
|
90
98
|
const lang = (ctx.config?.language as string) || 'en';
|
|
91
99
|
const isZh = lang === 'zh';
|
|
92
|
-
|
|
93
|
-
const {sessionId} = (ctx as any);
|
|
100
|
+
const { sessionId } = ctx as SessionAwareCommandContext;
|
|
94
101
|
|
|
95
102
|
const args = (ctx.args || '').trim();
|
|
96
103
|
|
|
97
104
|
// Handle empathy subcommand
|
|
98
105
|
if (args.startsWith('empathy')) {
|
|
99
106
|
|
|
100
|
-
|
|
107
|
+
|
|
101
108
|
return handleEmpathySubcommand(wctx, args, sessionId, isZh);
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -138,7 +145,7 @@ export function handlePainCommand(ctx: PluginCommandContext): PluginCommandResul
|
|
|
138
145
|
|
|
139
146
|
// Determine health status based on GFI
|
|
140
147
|
|
|
141
|
-
|
|
148
|
+
|
|
142
149
|
let healthLabel: string;
|
|
143
150
|
let suggestionText = '';
|
|
144
151
|
|
|
@@ -218,7 +225,7 @@ export function handlePainCommand(ctx: PluginCommandContext): PluginCommandResul
|
|
|
218
225
|
* Handle /pd-status empathy subcommand
|
|
219
226
|
*/
|
|
220
227
|
|
|
221
|
-
|
|
228
|
+
|
|
222
229
|
function handleEmpathySubcommand(
|
|
223
230
|
wctx: WorkspaceContext,
|
|
224
231
|
args: string,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from '../core/principle-tree-ledger.js';
|
|
29
29
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
30
30
|
import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
|
|
31
|
-
import type { Implementation } from '../types/principle-tree-schema.js';
|
|
31
|
+
import type { Implementation, ImplementationLifecycleState } from '../types/principle-tree-schema.js';
|
|
32
32
|
import { withLock } from '../utils/file-lock.js';
|
|
33
33
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
34
34
|
|
|
@@ -37,16 +37,23 @@ function getAllImplementations(stateDir: string): Implementation[] {
|
|
|
37
37
|
return Object.values(ledger.tree.implementations);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Type predicate: true if impl has lifecycleState of 'candidate' or 'disabled'.
|
|
42
|
+
* The ledger adds lifecycleState at runtime beyond what's in the manifest interface.
|
|
43
|
+
*/
|
|
44
|
+
function isCandidateOrDisabled(
|
|
45
|
+
impl: Implementation
|
|
46
|
+
): impl is Implementation & { lifecycleState: ImplementationLifecycleState } {
|
|
47
|
+
return impl.lifecycleState === 'candidate' || impl.lifecycleState === 'disabled';
|
|
48
|
+
}
|
|
49
|
+
|
|
40
50
|
function _handleListCandidates(
|
|
41
51
|
stateDir: string,
|
|
42
52
|
isZh: boolean,
|
|
43
53
|
): PluginCommandResult {
|
|
44
54
|
const engine = new ReplayEngine('', stateDir);
|
|
45
55
|
const allImpls = getAllImplementations(stateDir);
|
|
46
|
-
const candidates = allImpls.filter(
|
|
47
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: lifecycleState is a dynamic property added by the system - type not in official interface
|
|
48
|
-
(impl) => (impl as any).lifecycleState === 'candidate',
|
|
49
|
-
);
|
|
56
|
+
const candidates = allImpls.filter(isCandidateOrDisabled);
|
|
50
57
|
|
|
51
58
|
if (candidates.length === 0) {
|
|
52
59
|
return {
|
|
@@ -141,8 +148,7 @@ function _handlePromoteImpl(options: PromoteImplOptions): PluginCommandResult {
|
|
|
141
148
|
};
|
|
142
149
|
}
|
|
143
150
|
|
|
144
|
-
|
|
145
|
-
const currentState = (candidate as any).lifecycleState || 'candidate';
|
|
151
|
+
const currentState = candidate.lifecycleState || 'candidate';
|
|
146
152
|
|
|
147
153
|
if (currentState !== 'candidate' && currentState !== 'disabled') {
|
|
148
154
|
return {
|
package/src/commands/rollback.ts
CHANGED
|
@@ -2,6 +2,14 @@ import { WorkspaceContext } from '../core/workspace-context.js';
|
|
|
2
2
|
import { resetFriction } from '../core/session-tracker.js';
|
|
3
3
|
import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Extended context interface that includes sessionId injected by the plugin framework.
|
|
7
|
+
* PluginCommandContext does not include sessionId in its type definition.
|
|
8
|
+
*/
|
|
9
|
+
interface SessionAwareCommandContext extends PluginCommandContext {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
5
13
|
/**
|
|
6
14
|
* Handles the /pd-rollback command
|
|
7
15
|
*
|
|
@@ -15,8 +23,7 @@ export function handleRollbackCommand(ctx: PluginCommandContext): PluginCommandR
|
|
|
15
23
|
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
|
|
16
24
|
const lang = (ctx.config?.language as string) || 'en';
|
|
17
25
|
const isZh = lang === 'zh';
|
|
18
|
-
|
|
19
|
-
const {sessionId} = (ctx as any);
|
|
26
|
+
const { sessionId } = ctx as SessionAwareCommandContext;
|
|
20
27
|
|
|
21
28
|
const args = (ctx.args || '').trim();
|
|
22
29
|
|
|
@@ -45,7 +52,7 @@ Usage:
|
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
|
|
48
|
-
|
|
55
|
+
|
|
49
56
|
let eventId: string | null;
|
|
50
57
|
|
|
51
58
|
const _triggerMethod = 'user_command' as const;
|
package/src/core/event-log.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import * as fs from 'fs';
|
|
2
3
|
import * as path from 'path';
|
|
3
4
|
import type {
|
|
@@ -94,7 +95,7 @@ export class EventLog {
|
|
|
94
95
|
/**
|
|
95
96
|
* Clean up event files older than EVENT_LOG_RETENTION_DAYS.
|
|
96
97
|
*/
|
|
97
|
-
private cleanupOldEventFiles(
|
|
98
|
+
private cleanupOldEventFiles(_today: string): void {
|
|
98
99
|
if (EVENT_LOG_RETENTION_DAYS <= 0) return;
|
|
99
100
|
|
|
100
101
|
try {
|
|
@@ -110,8 +111,8 @@ export class EventLog {
|
|
|
110
111
|
fs.unlinkSync(filePath);
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
|
-
} catch {
|
|
114
|
-
|
|
114
|
+
} catch (err) {
|
|
115
|
+
this.logger?.debug?.(`[PD] Event file cleanup failed (non-blocking): ${String(err)}`);
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -220,6 +221,7 @@ export class EventLog {
|
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
/* eslint-disable complexity */
|
|
223
225
|
private updateStats(entry: EventLogEntry): void {
|
|
224
226
|
let stats = this.statsCache.get(entry.date);
|
|
225
227
|
if (!stats) {
|
|
@@ -228,8 +230,6 @@ export class EventLog {
|
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
if (entry.type === 'tool_call') {
|
|
231
|
-
|
|
232
|
-
const _data = entry.data as unknown as ToolCallEventData;
|
|
233
233
|
stats.tools.total++;
|
|
234
234
|
if (entry.category === 'success') stats.tools.success++;
|
|
235
235
|
else stats.tools.failure++;
|
|
@@ -349,7 +349,8 @@ export class EventLog {
|
|
|
349
349
|
.map((line) => {
|
|
350
350
|
try {
|
|
351
351
|
return JSON.parse(line) as EventLogEntry;
|
|
352
|
-
} catch {
|
|
352
|
+
} catch (err) {
|
|
353
|
+
this.logger?.warn?.(`[PD] Corrupted event line skipped: ${String(err).slice(0, 100)}`);
|
|
353
354
|
return null;
|
|
354
355
|
}
|
|
355
356
|
})
|
|
@@ -499,6 +500,7 @@ export class EventLog {
|
|
|
499
500
|
/**
|
|
500
501
|
* Aggregate empathy stats for a specific session.
|
|
501
502
|
*/
|
|
503
|
+
/* eslint-disable complexity */
|
|
502
504
|
private aggregateSessionEmpathy(sessionId: string, result: EmpathyEventStats): void {
|
|
503
505
|
for (const entry of this.getMergedEvents()) {
|
|
504
506
|
if (entry.sessionId === sessionId && entry.type === 'pain_signal') {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Evolution Points System V2.0 - MVP
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Core Philosophy: Growth-driven替代Penalty-driven
|
|
5
5
|
* - 起点0分,只能增加,不扣分
|
|
6
6
|
* - 失败记录教训,不扣分
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* - 5级成长路径:Seed → Forest
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
// V2 queue types require TaskKind/TaskPriority from trajectory-types
|
|
12
|
+
import type { TaskKind, TaskPriority } from './trajectory-types.js';
|
|
13
|
+
|
|
11
14
|
// ===== 等级定义 =====
|
|
12
15
|
|
|
13
16
|
|
|
@@ -464,3 +467,32 @@ export type EvolutionLoopEvent =
|
|
|
464
467
|
| { ts: string; type: 'principle_rolled_back'; data: PrincipleRolledBackData }
|
|
465
468
|
| { ts: string; type: 'circuit_breaker_opened'; data: CircuitBreakerOpenedData }
|
|
466
469
|
| { ts: string; type: 'legacy_import'; data: LegacyImportData };
|
|
470
|
+
|
|
471
|
+
// V2 Queue Types (moved from evolution-worker.ts for shared use)
|
|
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';
|
|
474
|
+
|
|
475
|
+
export interface EvolutionQueueItem {
|
|
476
|
+
id: string;
|
|
477
|
+
taskKind: TaskKind;
|
|
478
|
+
priority: TaskPriority;
|
|
479
|
+
source: string;
|
|
480
|
+
traceId?: string;
|
|
481
|
+
task?: string;
|
|
482
|
+
score: number;
|
|
483
|
+
reason: string;
|
|
484
|
+
timestamp: string;
|
|
485
|
+
enqueued_at?: string;
|
|
486
|
+
started_at?: string;
|
|
487
|
+
completed_at?: string;
|
|
488
|
+
assigned_session_key?: string;
|
|
489
|
+
trigger_text_preview?: string;
|
|
490
|
+
status: QueueStatus;
|
|
491
|
+
resolution?: TaskResolution;
|
|
492
|
+
session_id?: string;
|
|
493
|
+
agent_id?: string;
|
|
494
|
+
retryCount: number;
|
|
495
|
+
maxRetries: number;
|
|
496
|
+
lastError?: string;
|
|
497
|
+
resultRef?: string;
|
|
498
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code Validator — Validates LLM-generated rule implementation code
|
|
3
|
+
*
|
|
4
|
+
* PURPOSE: Ensure generated code is safe, syntactically correct, and exports
|
|
5
|
+
* the expected shape before it is stored as a rule implementation.
|
|
6
|
+
*
|
|
7
|
+
* CHECKS:
|
|
8
|
+
* 1. Syntax: code parses without errors
|
|
9
|
+
* 2. Forbidden patterns: no require, import, fetch, eval, Function, process, globalThis
|
|
10
|
+
* 3. Export check: sandbox loads and exports evaluate + meta
|
|
11
|
+
* 4. Return shape: evaluate(mockInput) returns { matched: boolean }
|
|
12
|
+
*
|
|
13
|
+
* Reuses loadRuleImplementationModule for sandbox execution (node:vm isolation).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { nodeVm } from '../../utils/node-vm-polyfill.js';
|
|
17
|
+
import { loadRuleImplementationModule } from '../rule-implementation-runtime.js';
|
|
18
|
+
|
|
19
|
+
export interface ValidationResult {
|
|
20
|
+
valid: boolean;
|
|
21
|
+
errors: string[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FORBIDDEN_PATTERNS: { pattern: RegExp; label: string }[] = [
|
|
26
|
+
{ pattern: /\brequire\s*\(/, label: 'require' },
|
|
27
|
+
{ pattern: /\bimport\s+/, label: 'import' },
|
|
28
|
+
{ pattern: /\bfetch\s*\(/, label: 'fetch' },
|
|
29
|
+
{ pattern: /\beval\s*\(/, label: 'eval' },
|
|
30
|
+
{ pattern: /\bFunction\s*\(/, label: 'Function' },
|
|
31
|
+
{ pattern: /\bprocess\b/, label: 'process' },
|
|
32
|
+
{ pattern: /\bglobalThis\b/, label: 'globalThis' },
|
|
33
|
+
{ pattern: /\bglobal\b/, label: 'global' },
|
|
34
|
+
{ pattern: /\bReflect\b/, label: 'Reflect' },
|
|
35
|
+
{ pattern: /\bProxy\b/, label: 'Proxy' },
|
|
36
|
+
{ pattern: /\bconstructor\b/, label: 'constructor' },
|
|
37
|
+
{ pattern: /\bBuffer\b/, label: 'Buffer' },
|
|
38
|
+
{ pattern: /\bsetTimeout\b/, label: 'setTimeout' },
|
|
39
|
+
{ pattern: /\bsetInterval\b/, label: 'setInterval' },
|
|
40
|
+
// Bracket notation access to globals
|
|
41
|
+
{ pattern: /\[\s*['"](require|import|fetch|eval|process|globalThis|global|Reflect|Proxy|Buffer|Function)\s*['"]\s*\]/, label: 'bracket access to forbidden global' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const MOCK_INPUT = {
|
|
45
|
+
action: {
|
|
46
|
+
toolName: 'bash',
|
|
47
|
+
normalizedPath: '/tmp/test.ts',
|
|
48
|
+
paramsSummary: { command: 'echo test' },
|
|
49
|
+
},
|
|
50
|
+
workspace: { isRiskPath: false, planStatus: 'NONE', hasPlanFile: false },
|
|
51
|
+
session: { sessionId: 'test', currentGfi: 0, recentThinking: false },
|
|
52
|
+
evolution: { epTier: 0 },
|
|
53
|
+
derived: { estimatedLineChanges: 0, bashRisk: 'safe' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function validateGeneratedCode(code: string): ValidationResult {
|
|
57
|
+
const errors: string[] = [];
|
|
58
|
+
const warnings: string[] = [];
|
|
59
|
+
|
|
60
|
+
// --- Check 1: Syntax ---
|
|
61
|
+
// Normalize export keywords so vm.Script can parse ES module source
|
|
62
|
+
const normalized = code
|
|
63
|
+
.replace(/export\s+const\s+/g, 'const ')
|
|
64
|
+
.replace(/export\s+function\s+/g, 'function ');
|
|
65
|
+
try {
|
|
66
|
+
new nodeVm.Script(normalized, { filename: 'code-validator-syntax.js' });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
errors.push(`Syntax error: ${(err as Error).message}`);
|
|
69
|
+
return { valid: false, errors, warnings };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Check 2: Forbidden patterns ---
|
|
73
|
+
for (const { pattern, label } of FORBIDDEN_PATTERNS) {
|
|
74
|
+
if (pattern.test(code)) {
|
|
75
|
+
errors.push(`Forbidden pattern: ${label}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (errors.length > 0) {
|
|
80
|
+
return { valid: false, errors, warnings };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Check 3: Sandbox load + export check ---
|
|
84
|
+
let moduleExports: { meta?: unknown; evaluate?: unknown };
|
|
85
|
+
try {
|
|
86
|
+
moduleExports = loadRuleImplementationModule(code, 'code-validator-candidate.js');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
errors.push(`Sandbox compilation error: ${(err as Error).message}`);
|
|
89
|
+
return { valid: false, errors, warnings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!moduleExports.meta || typeof moduleExports.meta !== 'object') {
|
|
93
|
+
errors.push('Missing export: meta');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof moduleExports.evaluate !== 'function') {
|
|
97
|
+
errors.push('Missing export: evaluate');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (errors.length > 0) {
|
|
101
|
+
return { valid: false, errors, warnings };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Check 4: Return shape ---
|
|
105
|
+
try {
|
|
106
|
+
const result = (moduleExports.evaluate as (input: unknown) => unknown)(MOCK_INPUT);
|
|
107
|
+
if (!result || typeof result !== 'object') {
|
|
108
|
+
errors.push('evaluate must return an object');
|
|
109
|
+
} else if (typeof (result as Record<string, unknown>).matched !== 'boolean') {
|
|
110
|
+
errors.push('evaluate must return { matched: boolean }');
|
|
111
|
+
}
|
|
112
|
+
} catch (evalWarning) {
|
|
113
|
+
// evaluate throwing on mock input is acceptable — the function exists and
|
|
114
|
+
// has the right signature, it just can't handle our generic mock data.
|
|
115
|
+
// Track as a non-blocking warning so operators know the rule may be fragile.
|
|
116
|
+
warnings.push(`evaluate() threw on mock input: ${(evalWarning as Error).message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
120
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PrincipleCompiler — Orchestrator (Task 5)
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the full compilation flow:
|
|
5
|
+
* ReflectionContextCollector.collect() → extract patterns → generateFromTemplate()
|
|
6
|
+
* → validateGeneratedCode() → registerCompiledRule()
|
|
7
|
+
*
|
|
8
|
+
* DESIGN DECISIONS:
|
|
9
|
+
* - extractPatterns infers toolName from pain event reasons and session tool calls
|
|
10
|
+
* - Groups by toolName into PainPattern objects
|
|
11
|
+
* - If no patterns can be extracted, returns a 'no patterns' failure
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ReflectionContextCollector } from '../reflection/reflection-context.js';
|
|
15
|
+
import { validateGeneratedCode } from './code-validator.js';
|
|
16
|
+
import { generateFromTemplate, type PainPattern } from './template-generator.js';
|
|
17
|
+
import { registerCompiledRule } from './ledger-registrar.js';
|
|
18
|
+
import { createImplementationAssetDir } from '../code-implementation-storage.js';
|
|
19
|
+
import type { TrajectoryDatabase } from '../trajectory.js';
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface CompileResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
principleId: string;
|
|
28
|
+
ruleId?: string;
|
|
29
|
+
implementationId?: string;
|
|
30
|
+
code?: string;
|
|
31
|
+
reason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Constants
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Tool names to look for when scanning text for tool references */
|
|
39
|
+
const KNOWN_TOOLS = ['bash', 'write', 'edit', 'read', 'grep', 'glob', 'mcp'] as const;
|
|
40
|
+
|
|
41
|
+
/** Regex to extract file paths from reason text */
|
|
42
|
+
const PATH_REGEX = /(?:\/[\w.-]+){2,}/;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Pattern Extraction
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract PainPatterns from a ReflectionContext.
|
|
50
|
+
*
|
|
51
|
+
* Strategy:
|
|
52
|
+
* 1. Scan pain event reasons for known tool names
|
|
53
|
+
* 2. Extract file paths from reason text as pathRegex candidates
|
|
54
|
+
* 3. Cross-reference with sessionSnapshot toolCalls for failed tool calls
|
|
55
|
+
* 4. Group by toolName into PainPattern objects
|
|
56
|
+
*/
|
|
57
|
+
function extractPatterns(context: {
|
|
58
|
+
painEvents: Array<{ reason: string | null; source: string }>;
|
|
59
|
+
sessionSnapshot: {
|
|
60
|
+
toolCalls: Array<{
|
|
61
|
+
toolName: string;
|
|
62
|
+
outcome: string;
|
|
63
|
+
filePath: string | null;
|
|
64
|
+
errorType: string | null;
|
|
65
|
+
}>;
|
|
66
|
+
} | null;
|
|
67
|
+
}): PainPattern[] {
|
|
68
|
+
const toolNameMap = new Map<string, PainPattern>();
|
|
69
|
+
|
|
70
|
+
// 1. Extract from pain event reasons
|
|
71
|
+
for (const pe of context.painEvents) {
|
|
72
|
+
const text = pe.reason ?? pe.source ?? '';
|
|
73
|
+
const toolName = inferToolName(text);
|
|
74
|
+
if (!toolName) continue;
|
|
75
|
+
|
|
76
|
+
const pathRegex = extractPathRegex(text);
|
|
77
|
+
|
|
78
|
+
if (!toolNameMap.has(toolName)) {
|
|
79
|
+
toolNameMap.set(toolName, { toolName });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const pattern = toolNameMap.get(toolName)!;
|
|
83
|
+
if (pathRegex && !pattern.pathRegex) {
|
|
84
|
+
pattern.pathRegex = pathRegex;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Extract from session snapshot tool calls (failed ones)
|
|
89
|
+
if (context.sessionSnapshot?.toolCalls) {
|
|
90
|
+
for (const tc of context.sessionSnapshot.toolCalls) {
|
|
91
|
+
// Focus on failed/blocked tool calls as they indicate pain
|
|
92
|
+
if (tc.outcome !== 'failure' && tc.outcome !== 'blocked') continue;
|
|
93
|
+
|
|
94
|
+
const toolName = tc.toolName;
|
|
95
|
+
if (!toolNameMap.has(toolName)) {
|
|
96
|
+
const pattern: PainPattern = { toolName };
|
|
97
|
+
if (tc.errorType) {
|
|
98
|
+
pattern.errorType = tc.errorType;
|
|
99
|
+
}
|
|
100
|
+
if (tc.filePath) {
|
|
101
|
+
pattern.pathRegex = escapeRegex(tc.filePath);
|
|
102
|
+
}
|
|
103
|
+
toolNameMap.set(toolName, pattern);
|
|
104
|
+
} else {
|
|
105
|
+
const existing = toolNameMap.get(toolName)!;
|
|
106
|
+
if (tc.errorType && !existing.errorType) {
|
|
107
|
+
existing.errorType = tc.errorType;
|
|
108
|
+
}
|
|
109
|
+
if (tc.filePath && !existing.pathRegex) {
|
|
110
|
+
existing.pathRegex = escapeRegex(tc.filePath);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Array.from(toolNameMap.values());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Infer tool name from text by checking for known tool names.
|
|
121
|
+
* Returns the first matching known tool name, or null if none found.
|
|
122
|
+
*/
|
|
123
|
+
function inferToolName(text: string): string | null {
|
|
124
|
+
const lower = text.toLowerCase();
|
|
125
|
+
for (const tool of KNOWN_TOOLS) {
|
|
126
|
+
// Match as a standalone word to avoid false positives
|
|
127
|
+
// e.g., "bash" in "bash" or "bash command" but not in "ambush"
|
|
128
|
+
const regex = new RegExp(`\\b${tool}\\b`);
|
|
129
|
+
if (regex.test(lower)) {
|
|
130
|
+
return tool;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract a file path from text and return it as an escaped regex pattern.
|
|
138
|
+
* Returns the first path found, or null.
|
|
139
|
+
*/
|
|
140
|
+
function extractPathRegex(text: string): string | null {
|
|
141
|
+
const match = PATH_REGEX.exec(text);
|
|
142
|
+
if (match) {
|
|
143
|
+
return escapeRegex(match[0]);
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Escape special regex characters in a string.
|
|
150
|
+
*/
|
|
151
|
+
function escapeRegex(str: string): string {
|
|
152
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// PrincipleCompiler
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export class PrincipleCompiler {
|
|
160
|
+
private readonly stateDir: string;
|
|
161
|
+
private readonly collector: ReflectionContextCollector;
|
|
162
|
+
|
|
163
|
+
constructor(stateDir: string, trajectory: TrajectoryDatabase) {
|
|
164
|
+
this.stateDir = stateDir;
|
|
165
|
+
this.collector = new ReflectionContextCollector(stateDir, trajectory);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Compile a single principle into an auto-generated rule.
|
|
170
|
+
*
|
|
171
|
+
* Flow:
|
|
172
|
+
* 1. Collect reflection context
|
|
173
|
+
* 2. Extract pain patterns
|
|
174
|
+
* 3. Generate code from template
|
|
175
|
+
* 4. Validate generated code
|
|
176
|
+
* 5. Register in ledger
|
|
177
|
+
*/
|
|
178
|
+
compileOne(principleId: string): CompileResult {
|
|
179
|
+
// Step 1: Collect context
|
|
180
|
+
const context = this.collector.collect(principleId);
|
|
181
|
+
if (!context) {
|
|
182
|
+
return { success: false, principleId, reason: 'no context' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Step 2: Extract patterns
|
|
186
|
+
const patterns = extractPatterns({
|
|
187
|
+
painEvents: context.painEvents,
|
|
188
|
+
sessionSnapshot: context.sessionSnapshot,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Step 3: Generate code
|
|
192
|
+
const coversCondition = context.principle.triggerPattern || context.principle.text;
|
|
193
|
+
const code = generateFromTemplate(principleId, coversCondition, patterns);
|
|
194
|
+
if (!code) {
|
|
195
|
+
return { success: false, principleId, reason: 'no patterns' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Step 4: Validate
|
|
199
|
+
const validation = validateGeneratedCode(code);
|
|
200
|
+
if (!validation.valid) {
|
|
201
|
+
return {
|
|
202
|
+
success: false,
|
|
203
|
+
principleId,
|
|
204
|
+
reason: `validation failed: ${validation.errors.join('; ')}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Step 5: Register
|
|
209
|
+
const registration = registerCompiledRule(this.stateDir, {
|
|
210
|
+
principleId,
|
|
211
|
+
codeContent: code,
|
|
212
|
+
coversCondition,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Step 6: Persist code to disk so RuleHost can load it
|
|
216
|
+
createImplementationAssetDir(this.stateDir, registration.implementationId, '1', {
|
|
217
|
+
entrySource: code,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
success: true,
|
|
222
|
+
principleId,
|
|
223
|
+
ruleId: registration.ruleId,
|
|
224
|
+
implementationId: registration.implementationId,
|
|
225
|
+
code,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compile all eligible principles (those with derivedFromPainIds).
|
|
231
|
+
*/
|
|
232
|
+
compileAll(): CompileResult[] {
|
|
233
|
+
const contexts = this.collector.collectBatch();
|
|
234
|
+
return contexts.map((ctx) => {
|
|
235
|
+
try {
|
|
236
|
+
return this.compileOne(ctx.principle.id);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return { success: false, principleId: ctx.principle.id, reason: `unhandled: ${(e as Error).message}` };
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Principle Compiler — Barrel Export
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all principle-compiler components for convenient importing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { PrincipleCompiler, type CompileResult } from './compiler.js';
|
|
8
|
+
export { validateGeneratedCode, type ValidationResult } from './code-validator.js';
|
|
9
|
+
export { generateFromTemplate, type PainPattern } from './template-generator.js';
|
|
10
|
+
export { registerCompiledRule, type RegisterInput, type RegisterResult } from './ledger-registrar.js';
|