principles-disciple 1.40.0 → 1.41.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.
@@ -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';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Ledger Registrar (Task 4)
3
+ *
4
+ * Registers a compiled rule into the principle tree ledger:
5
+ * 1. Creates a LedgerRule with type 'gate', enforcement 'block', status 'proposed'
6
+ * 2. Creates an Implementation with type 'code', lifecycleState 'candidate'
7
+ *
8
+ * IDEMPOTENCY: If the rule already exists, returns existing registration.
9
+ * ROLLBACK: If implementation creation fails after rule creation, attempts cleanup.
10
+ */
11
+
12
+ import { createRule, createImplementation, loadLedger, deleteRule, type LedgerRule } from '../principle-tree-ledger.js';
13
+
14
+ export interface RegisterInput {
15
+ principleId: string;
16
+ codeContent: string;
17
+ coversCondition: string;
18
+ }
19
+
20
+ export interface RegisterResult {
21
+ success: boolean;
22
+ ruleId: string;
23
+ implementationId: string;
24
+ codePath: string;
25
+ }
26
+
27
+ /**
28
+ * Register a compiled rule for a principle in the ledger.
29
+ *
30
+ * Idempotent: if rule already exists, returns existing registration.
31
+ * Atomic: if implementation creation fails, rolls back the rule.
32
+ */
33
+ export function registerCompiledRule(stateDir: string, input: RegisterInput): RegisterResult {
34
+ const { principleId, codeContent, coversCondition } = input;
35
+
36
+ const ruleId = `R_${principleId}_auto`;
37
+ const implementationId = `IMPL_${principleId}_auto`;
38
+ const codePath = `compiled-rules/${principleId}/rule.ts`;
39
+
40
+ // Idempotency: skip if rule already exists
41
+ const existingLedger = loadLedger(stateDir);
42
+ if (existingLedger.tree.rules[ruleId]) {
43
+ const existingRule = existingLedger.tree.rules[ruleId];
44
+ return {
45
+ success: true,
46
+ ruleId,
47
+ implementationId: existingRule.implementationIds[0] ?? implementationId,
48
+ codePath,
49
+ };
50
+ }
51
+
52
+ const now = new Date().toISOString();
53
+
54
+ // Step 1: Create the rule
55
+ const rule: LedgerRule = {
56
+ id: ruleId,
57
+ version: 1,
58
+ name: `Auto-compiled rule for ${principleId}`,
59
+ description: `Automatically compiled gate rule generated from principle ${principleId}`,
60
+ type: 'gate',
61
+ triggerCondition: coversCondition,
62
+ enforcement: 'block',
63
+ action: codeContent,
64
+ principleId,
65
+ status: 'proposed',
66
+ coverageRate: 0,
67
+ falsePositiveRate: 0,
68
+ implementationIds: [],
69
+ createdAt: now,
70
+ updatedAt: now,
71
+ };
72
+
73
+ createRule(stateDir, rule);
74
+
75
+ // Step 2: Create the implementation (with rollback on failure)
76
+ try {
77
+ const implementation = {
78
+ id: implementationId,
79
+ ruleId,
80
+ type: 'code' as const,
81
+ path: codePath,
82
+ version: '1',
83
+ coversCondition,
84
+ coveragePercentage: 100,
85
+ lifecycleState: 'active' as const,
86
+ createdAt: now,
87
+ updatedAt: now,
88
+ };
89
+
90
+ createImplementation(stateDir, implementation);
91
+ } catch (implError) {
92
+ // Rollback: remove the orphaned rule
93
+ try {
94
+ deleteRule(stateDir, ruleId);
95
+ } catch {
96
+ // Best-effort rollback — log but don't mask the original error
97
+ }
98
+ throw implError;
99
+ }
100
+
101
+ return {
102
+ success: true,
103
+ ruleId,
104
+ implementationId,
105
+ codePath,
106
+ };
107
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Template Generator for Principle Compiler
3
+ *
4
+ * Generates RuleHost sandbox code from PainPattern descriptors.
5
+ * Produces self-contained JS modules with `export const meta` and
6
+ * `export function evaluate(input)` that can be loaded at runtime.
7
+ *
8
+ * SECURITY: All interpolated values use JSON.stringify or safe helpers
9
+ * to prevent code injection through principleId, coversCondition, or regex patterns.
10
+ */
11
+
12
+ export interface PainPattern {
13
+ toolName: string;
14
+ pathRegex?: string;
15
+ commandRegex?: string;
16
+ contentRegex?: string;
17
+ errorType?: string;
18
+ }
19
+
20
+ /**
21
+ * Derives the auto-rule display name from a principle ID.
22
+ */
23
+ function toAutoName(principleId: string): string {
24
+ return `Auto_${principleId}`;
25
+ }
26
+
27
+ /**
28
+ * Derives the auto-rule ID from a principle ID.
29
+ * Must match ledger-registrar convention: "P_066" => "R_P_066_auto"
30
+ */
31
+ function toAutoRuleId(principleId: string): string {
32
+ return `R_${principleId}_auto`;
33
+ }
34
+
35
+ /**
36
+ * Builds a single `if` branch for a pain pattern.
37
+ *
38
+ * SECURITY: Uses `new RegExp(JSON.stringify(...))` instead of regex literals
39
+ * to prevent code injection through pathRegex/commandRegex/contentRegex values.
40
+ * principleId in reason uses JSON.stringify to prevent string breakout.
41
+ */
42
+ function buildBranch(principleId: string, pattern: PainPattern): string {
43
+ const conditions: string[] = [];
44
+
45
+ conditions.push(`input.action.toolName === ${JSON.stringify(pattern.toolName)}`);
46
+
47
+ if (pattern.pathRegex) {
48
+ conditions.push(`new RegExp(${JSON.stringify(pattern.pathRegex)}).test(input.action.normalizedPath || '')`);
49
+ }
50
+
51
+ if (pattern.commandRegex) {
52
+ conditions.push(`new RegExp(${JSON.stringify(pattern.commandRegex)}).test(input.action.paramsSummary.command || '')`);
53
+ }
54
+
55
+ if (pattern.contentRegex) {
56
+ conditions.push(
57
+ `new RegExp(${JSON.stringify(pattern.contentRegex)}).test(input.action.paramsSummary.content || input.action.paramsSummary.new_string || '')`,
58
+ );
59
+ }
60
+
61
+ const guard = conditions.join(' && ');
62
+ const reason = `[${principleId}] Blocked by auto-generated rule`;
63
+
64
+ return (
65
+ ` if (${guard}) {\n` +
66
+ ` return { decision: 'block', matched: true, reason: ${JSON.stringify(reason)} };\n` +
67
+ ` }`
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Generates sandbox-ready JS code from a principle ID and pain patterns.
73
+ *
74
+ * Returns `null` when `patterns` is empty.
75
+ */
76
+ export function generateFromTemplate(
77
+ principleId: string,
78
+ coversCondition: string,
79
+ patterns: PainPattern[],
80
+ ): string | null {
81
+ if (patterns.length === 0) {
82
+ return null;
83
+ }
84
+
85
+ const name = toAutoName(principleId);
86
+ const ruleId = toAutoRuleId(principleId);
87
+ const compiledAt = new Date().toISOString();
88
+
89
+ const branches = patterns
90
+ .map((p) => buildBranch(principleId, p))
91
+ .join('\n');
92
+
93
+ return (
94
+ `// Auto-generated by Principle Compiler\n` +
95
+ `export const meta = {\n` +
96
+ ` name: ${JSON.stringify(name)},\n` +
97
+ ` version: '1.0.0',\n` +
98
+ ` ruleId: ${JSON.stringify(ruleId)},\n` +
99
+ ` coversCondition: ${JSON.stringify(coversCondition)},\n` +
100
+ ` compiledAt: ${JSON.stringify(compiledAt)},\n` +
101
+ ` sourcePrincipleId: ${JSON.stringify(principleId)},\n` +
102
+ `};\n\n` +
103
+ `export function evaluate(input) {\n` +
104
+ `${branches}\n` +
105
+ ` return { matched: false };\n` +
106
+ `}\n`
107
+ );
108
+ }