principles-disciple 1.92.0 → 1.94.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,75 @@
1
+ /**
2
+ * Trajectory Evidence Builder — PRI-326
3
+ *
4
+ * Extracted from pain.ts to avoid circular imports between
5
+ * pain.ts and after-tool-call-helpers.ts.
6
+ *
7
+ * Pure data extraction — reads from trajectory DB, sanitizes, returns evidence entries.
8
+ */
9
+
10
+ import { sanitizeAssistantText } from './message-sanitize.js';
11
+ import type { PainEvidenceEntry } from '@principles/core/runtime-v2';
12
+ import { MAX_EVIDENCE_ENTRIES, MAX_EVIDENCE_NOTE_CHARS } from '@principles/core/runtime-v2';
13
+ import type { WorkspaceContext } from '../core/workspace-context.js';
14
+
15
+ export function buildTrajectoryEvidence(wctx: WorkspaceContext, sessionId: string): PainEvidenceEntry[] {
16
+ const evidence: PainEvidenceEntry[] = [];
17
+
18
+ if (!wctx.trajectory || sessionId === 'unknown') {
19
+ evidence.push({
20
+ sourceRef: 'owner_message:unavailable',
21
+ note: `trajectory_unavailable: ${!wctx.trajectory ? 'no_trajectory_db' : 'unknown_session'}`,
22
+ });
23
+ return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
24
+ }
25
+
26
+ try {
27
+ const userTurns = wctx.trajectory.listUserTurnsForSession(sessionId) ?? [];
28
+ const lastCorrectionTurn = [...userTurns].reverse().find(t => t.correctionDetected);
29
+ if (lastCorrectionTurn) {
30
+ const sanitizedOwnerMessage = sanitizeAssistantText(
31
+ (lastCorrectionTurn.rawExcerpt ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
32
+ );
33
+ evidence.push({
34
+ sourceRef: `owner_message:${lastCorrectionTurn.createdAt}`,
35
+ note: sanitizedOwnerMessage,
36
+ });
37
+ }
38
+ } catch (e) {
39
+ evidence.push({
40
+ sourceRef: 'owner_message:unavailable',
41
+ note: `trajectory_user_turns_unavailable: ${String(e).slice(0, 100)}`,
42
+ });
43
+ }
44
+
45
+ try {
46
+ const assistantTurns = wctx.trajectory.listAssistantTurns(sessionId) ?? [];
47
+ const recentAssistant = assistantTurns.slice(-3);
48
+ for (const turn of recentAssistant) {
49
+ if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
50
+ const sanitizedNote = sanitizeAssistantText(
51
+ (turn.sanitizedText ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
52
+ );
53
+ evidence.push({
54
+ sourceRef: `agent_turn:${turn.createdAt}`,
55
+ note: sanitizedNote,
56
+ });
57
+ }
58
+ } catch (e) {
59
+ if (evidence.length < MAX_EVIDENCE_ENTRIES) {
60
+ evidence.push({
61
+ sourceRef: 'agent_turn:unavailable',
62
+ note: `trajectory_assistant_turns_unavailable: ${String(e).slice(0, 100)}`,
63
+ });
64
+ }
65
+ }
66
+
67
+ if (evidence.length === 0) {
68
+ evidence.push({
69
+ sourceRef: 'trajectory:empty',
70
+ note: 'trajectory_available_but_empty: no user correction or assistant turns found',
71
+ });
72
+ }
73
+
74
+ return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
75
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Triage Adapter — PEAT-B1
3
+ *
4
+ * Plugin-side adapter that maps OpenClaw hook context to evidence triage input.
5
+ * Calls the pure triage policy from principles-core.
6
+ *
7
+ * This file lives in openclaw-plugin because it:
8
+ * - Maps hook-specific context (source strings, session state) to SourceKind
9
+ * - Wraps evaluatePainDiagnosticGate as a compatibility sub-policy
10
+ * - Knows about OpenClaw hook conventions (sessionId, toolName, etc.)
11
+ *
12
+ * It does NOT expose evaluatePainDiagnosticGate to core.
13
+ * Core only sees SourceKind and TriageResult.
14
+ *
15
+ * ERR checklist:
16
+ * - ERR-001: Source kind derived from runtime values with guards, not `as` casts.
17
+ * - ERR-002: Every triage result carries reason + nextAction.
18
+ * - ERR-024/025/048: Production-path tests cover this adapter.
19
+ */
20
+
21
+ import {
22
+ evaluateTriage,
23
+ type TriageInput,
24
+ type TriageResult,
25
+ type SourceKind,
26
+ } from '@principles/core/runtime-v2';
27
+
28
+ // ── Source Kind Resolution ───────────────────────────────────────────────────
29
+
30
+ /**
31
+ * Map after_tool_call hook context to SourceKind.
32
+ *
33
+ * Classifies based on:
34
+ * - toolName: 'pain' or 'skill:pain' → agent_on_owner_request
35
+ * - failureSource: 'dispatch_error' vs 'tool_failure'
36
+ * - isRisky + score: only used for rulehost_block upgrade, not for kind resolution
37
+ */
38
+ export function resolveSourceKindFromToolFailure(
39
+ toolName: string | undefined,
40
+ failureSource: 'tool_failure' | 'dispatch_error',
41
+ provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook',
42
+ ): SourceKind {
43
+ // Manual pain via agent tool call
44
+ if (toolName === 'pain' || toolName === 'skill:pain') {
45
+ return provenance === 'openclaw_context_bound' ? 'agent_on_owner_request' : 'owner_reported';
46
+ }
47
+
48
+ // Dispatch errors (tool not found, unknown tool)
49
+ if (failureSource === 'dispatch_error') {
50
+ return 'dispatch_error';
51
+ }
52
+
53
+ // Regular tool failure
54
+ return 'tool_failure';
55
+ }
56
+
57
+ /**
58
+ * Map empathy/semantic detection context to SourceKind.
59
+ *
60
+ * Classifies based on detection source prefix:
61
+ * - 'llm_paralysis' → llm_paralysis
62
+ * - 'llm_*' (detection rule) → semantic
63
+ * - 'user_empathy' or empathy keyword match → empathy_inferred
64
+ * - GFI threshold crossed → gfi_threshold
65
+ */
66
+ export function resolveSourceKindFromLlmDetection(
67
+ detectionSource: string,
68
+ isGfiTriggered: boolean,
69
+ ): SourceKind {
70
+ if (isGfiTriggered) return 'gfi_threshold';
71
+ if (detectionSource === 'llm_paralysis') return 'llm_paralysis';
72
+ if (detectionSource.startsWith('llm_')) return 'semantic';
73
+ if (detectionSource === 'user_empathy') return 'empathy_inferred';
74
+ return 'unknown';
75
+ }
76
+
77
+ /**
78
+ * Map gate-block context to SourceKind.
79
+ */
80
+ export function resolveSourceKindFromGateBlock(): SourceKind {
81
+ return 'rulehost_block';
82
+ }
83
+
84
+ /**
85
+ * Map /pd-pain command to SourceKind.
86
+ */
87
+ export function resolveSourceKindFromCommand(): SourceKind {
88
+ return 'owner_reported';
89
+ }
90
+
91
+ /**
92
+ * Map provider/rate-limit failure to SourceKind.
93
+ */
94
+ export function resolveSourceKindFromProvider(
95
+ isRateLimit: boolean,
96
+ ): SourceKind {
97
+ return isRateLimit ? 'rate_limit' : 'provider_failure';
98
+ }
99
+
100
+ /**
101
+ * Map subagent error to SourceKind.
102
+ */
103
+ export function resolveSourceKindFromSubagent(): SourceKind {
104
+ return 'subagent_error';
105
+ }
106
+
107
+ // ── Triage Evaluation ───────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Evaluate evidence triage for a given source kind and context.
111
+ *
112
+ * This is the main entry point for hooks. It calls the pure triage policy
113
+ * from principles-core and returns the result.
114
+ *
115
+ * The caller (hook) is responsible for:
116
+ * - Checking the painEvidenceAdmission feature flag
117
+ * - Acting on the triage result (proceed to diagnosis, store evidence, etc.)
118
+ * - Falling back to existing behavior when the flag is off
119
+ */
120
+ export function evaluateEvidenceTriage(
121
+ sourceKind: SourceKind,
122
+ score: number,
123
+ options?: {
124
+ isUnsafeHighConfidence?: boolean;
125
+ provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook';
126
+ },
127
+ ): TriageResult {
128
+ const input: TriageInput = {
129
+ sourceKind,
130
+ score,
131
+ isUnsafeHighConfidence: options?.isUnsafeHighConfidence,
132
+ provenance: options?.provenance,
133
+ };
134
+
135
+ return evaluateTriage(input);
136
+ }
137
+
138
+ // ── High-Confidence Unsafe Action Detection ──────────────────────────────────
139
+
140
+ /**
141
+ * Determine if a gate-blocked action is a high-confidence unsafe action.
142
+ *
143
+ * This is a heuristic that the plugin adapter owns. Core does not know about
144
+ * these heuristics — it only receives the boolean flag.
145
+ *
146
+ * Criteria for high-confidence unsafe:
147
+ * - Score >= 70 (high severity)
148
+ * - Tool is in the risky write set
149
+ * - Action would be irreversible (file deletion, force push, etc.)
150
+ */
151
+ export function isHighConfidenceUnsafeAction(
152
+ score: number,
153
+ isRisky: boolean,
154
+ ): boolean {
155
+ return isRisky && score >= 70;
156
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Gate Block Helper — PROFILE loading resilience tests
3
+ *
4
+ * Verifies that recordGateBlockAndReturn handles malformed/oversized PROFILE
5
+ * gracefully: try/catch, 1MB size guard, fallback to non-risky, no crash.
6
+ *
7
+ * ERR checklist:
8
+ * - ERR-026: All PROFILE loads have try/catch (gate-block-helper.ts matches pain.ts)
9
+ * - ERR-024/025: Production-path tests for the edge case
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import * as os from 'os';
16
+ import { WorkspaceContext } from '../../src/core/workspace-context.js';
17
+ import { EventLogService } from '../../src/core/event-log.js';
18
+ import { clearSession } from '../../src/core/session-tracker.js';
19
+ import { resetPainDiagnosticGateForTest } from '../../src/core/pain-diagnostic-gate.js';
20
+
21
+ vi.mock('fs');
22
+ vi.mock('../../src/utils/io.js', () => ({
23
+ isRisky: vi.fn(() => false),
24
+ }));
25
+ vi.mock('../../src/core/evolution-engine.js', () => ({
26
+ recordEvolutionSuccess: vi.fn(),
27
+ recordEvolutionFailure: vi.fn(),
28
+ }));
29
+ vi.mock('../../src/core/evolution-logger.js', () => ({
30
+ createTraceId: vi.fn(() => 'trace-123'),
31
+ getEvolutionLogger: vi.fn(() => ({
32
+ logPainDetected: vi.fn(),
33
+ })),
34
+ }));
35
+ vi.mock('../../src/core/pd-config-loader.js', () => ({
36
+ loadPdConfigForPlugin: vi.fn(() => ({ ok: true, source: 'mock', effective: {}, errors: [] })),
37
+ loadFeatureFlagFromConfig: vi.fn(() => ({ enabled: true, source: 'test' })),
38
+ }));
39
+
40
+ const mockEmitSync = vi.fn();
41
+ const mockRecordProbationFeedback = vi.fn();
42
+ const mockUpdatePrincipleValueMetrics = vi.fn();
43
+
44
+ function makeTestWctx(overrides: Record<string, unknown> = {}) {
45
+ return {
46
+ workspaceDir: '/mock/workspace',
47
+ stateDir: '/mock/state',
48
+ config: { get: vi.fn().mockReturnValue(40) },
49
+ eventLog: {
50
+ recordGateBlock: vi.fn(),
51
+ recordPainSignal: vi.fn(),
52
+ },
53
+ trajectory: {
54
+ recordGateBlock: vi.fn(),
55
+ recordPainEvent: vi.fn(),
56
+ recordToolCall: vi.fn(),
57
+ },
58
+ principleTreeLedger: {
59
+ updatePrincipleValueMetrics: mockUpdatePrincipleValueMetrics,
60
+ },
61
+ evolutionReducer: {
62
+ emitSync: mockEmitSync,
63
+ recordProbationFeedback: mockRecordProbationFeedback,
64
+ getPrincipleById: vi.fn(),
65
+ },
66
+ resolve: vi.fn().mockImplementation((key: string) => {
67
+ if (key === 'PROFILE') return '/mock/workspace/PROFILE.json';
68
+ return '';
69
+ }),
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ describe('Gate Block Helper — PROFILE Resilience', () => {
75
+ const sessionId = 's-profile-test';
76
+
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ mockEmitSync.mockReset();
80
+ mockRecordProbationFeedback.mockReset();
81
+ mockUpdatePrincipleValueMetrics.mockReset();
82
+ vi.spyOn(WorkspaceContext, 'fromHookContext').mockReturnValue(makeTestWctx() as any);
83
+ vi.spyOn(EventLogService, 'get').mockReturnValue({} as any);
84
+ clearSession(sessionId);
85
+ resetPainDiagnosticGateForTest();
86
+ });
87
+
88
+ afterEach(() => {
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ it('malformed PROFILE.json does not throw, returns block result with non-risky fallback', async () => {
93
+ // Arrange: PROFILE exists but contains invalid JSON
94
+ vi.mocked(fs.existsSync).mockReturnValue(true);
95
+ vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }');
96
+
97
+ // Dynamic import AFTER mocks are set up
98
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
99
+
100
+ // Act & Assert: does NOT throw
101
+ const result = recordGateBlockAndReturn(
102
+ makeTestWctx() as any,
103
+ {
104
+ filePath: 'src/danger.ts',
105
+ reason: 'Test block reason',
106
+ toolName: 'write',
107
+ sessionId,
108
+ },
109
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
110
+ );
111
+
112
+ expect(result).toBeDefined();
113
+ expect(result.block).toBe(true);
114
+ expect(result.blockReason).toContain('Security Gate Blocked');
115
+ // verify emitPainDetectedEvent was NOT called (triage fell back to non-risky)
116
+ expect(mockEmitSync).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('oversized PROFILE (>1MB) falls back to non-risky without crash', async () => {
120
+ // Arrange: PROFILE > 1MB
121
+ vi.mocked(fs.existsSync).mockReturnValue(true);
122
+ vi.mocked(fs.readFileSync).mockReturnValue('x'.repeat(1024 * 1024 + 1));
123
+
124
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
125
+
126
+ const result = recordGateBlockAndReturn(
127
+ makeTestWctx() as any,
128
+ {
129
+ filePath: 'src/danger.ts',
130
+ reason: 'Test block reason',
131
+ toolName: 'write',
132
+ sessionId,
133
+ },
134
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
135
+ );
136
+
137
+ expect(result).toBeDefined();
138
+ expect(result.block).toBe(true);
139
+ expect(mockEmitSync).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('missing PROFILE.json defaults to non-risky without error', async () => {
143
+ // Arrange: PROFILE does not exist
144
+ vi.mocked(fs.existsSync).mockReturnValue(false);
145
+
146
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
147
+
148
+ const result = recordGateBlockAndReturn(
149
+ makeTestWctx() as any,
150
+ {
151
+ filePath: 'src/danger.ts',
152
+ reason: 'Test block',
153
+ toolName: 'edit',
154
+ sessionId,
155
+ },
156
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
157
+ );
158
+
159
+ expect(result).toBeDefined();
160
+ expect(result.block).toBe(true);
161
+ });
162
+
163
+ it('fs.readFileSync permission error falls back gracefully', async () => {
164
+ // Arrange: existsSync returns true but readFileSync throws
165
+ vi.mocked(fs.existsSync).mockReturnValue(true);
166
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
167
+ throw new Error('EACCES: permission denied');
168
+ });
169
+
170
+ const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
171
+
172
+ const result = recordGateBlockAndReturn(
173
+ makeTestWctx() as any,
174
+ {
175
+ filePath: 'src/danger.ts',
176
+ reason: 'Test block',
177
+ toolName: 'write',
178
+ sessionId,
179
+ },
180
+ { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
181
+ );
182
+
183
+ expect(result).toBeDefined();
184
+ expect(result.block).toBe(true);
185
+ });
186
+ });