principles-disciple 1.104.0 → 1.104.2

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.
@@ -28,81 +28,54 @@ import {
28
28
  // ── Source Kind Resolution ───────────────────────────────────────────────────
29
29
 
30
30
  /**
31
- * Map after_tool_call hook context to SourceKind.
31
+ * Map RawObservation to SourceKind.
32
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
33
+ * This is the unified entry point for source-kind classification.
34
+ * It replaces the scattered resolveSourceKindFrom* functions.
37
35
  */
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
- }
36
+ export { resolveSourceKind } from './raw-observation-adapter.js';
52
37
 
53
- // Regular tool failure
54
- return 'tool_failure';
55
- }
38
+ /**
39
+ * Map after_tool_call hook context to SourceKind.
40
+ *
41
+ * @deprecated Use resolveSourceKind directly with RawObservation.
42
+ */
43
+ export { resolveSourceKindFromToolFailure } from './raw-observation-adapter.js';
56
44
 
57
45
  /**
58
46
  * Map empathy/semantic detection context to SourceKind.
59
47
  *
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
48
+ * @deprecated Use resolveSourceKind directly with RawObservation.
65
49
  */
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
- }
50
+ export { resolveSourceKindFromLlmDetection } from './raw-observation-adapter.js';
76
51
 
77
52
  /**
78
53
  * Map gate-block context to SourceKind.
54
+ *
55
+ * @deprecated Use resolveSourceKind directly with RawObservation.
79
56
  */
80
- export function resolveSourceKindFromGateBlock(): SourceKind {
81
- return 'rulehost_block';
82
- }
57
+ export { resolveSourceKindFromGateBlock } from './raw-observation-adapter.js';
83
58
 
84
59
  /**
85
60
  * Map /pd-pain command to SourceKind.
61
+ *
62
+ * @deprecated Use resolveSourceKind directly with RawObservation.
86
63
  */
87
- export function resolveSourceKindFromCommand(): SourceKind {
88
- return 'owner_reported';
89
- }
64
+ export { resolveSourceKindFromCommand } from './raw-observation-adapter.js';
90
65
 
91
66
  /**
92
67
  * Map provider/rate-limit failure to SourceKind.
68
+ *
69
+ * @deprecated Use resolveSourceKind directly with RawObservation.
93
70
  */
94
- export function resolveSourceKindFromProvider(
95
- isRateLimit: boolean,
96
- ): SourceKind {
97
- return isRateLimit ? 'rate_limit' : 'provider_failure';
98
- }
71
+ export { resolveSourceKindFromProvider } from './raw-observation-adapter.js';
99
72
 
100
73
  /**
101
74
  * Map subagent error to SourceKind.
75
+ *
76
+ * @deprecated Use resolveSourceKind directly with RawObservation.
102
77
  */
103
- export function resolveSourceKindFromSubagent(): SourceKind {
104
- return 'subagent_error';
105
- }
78
+ export { resolveSourceKindFromSubagent } from './raw-observation-adapter.js';
106
79
 
107
80
  // ── Triage Evaluation ───────────────────────────────────────────────────────
108
81
 
@@ -123,6 +96,8 @@ export function evaluateEvidenceTriage(
123
96
  options?: {
124
97
  isUnsafeHighConfidence?: boolean;
125
98
  provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook';
99
+ consecutiveErrors?: number;
100
+ isRisky?: boolean;
126
101
  },
127
102
  ): TriageResult {
128
103
  const input: TriageInput = {
@@ -132,7 +107,39 @@ export function evaluateEvidenceTriage(
132
107
  provenance: options?.provenance,
133
108
  };
134
109
 
135
- return evaluateTriage(input);
110
+ let result = evaluateTriage(input);
111
+
112
+ // PEAT-B1 upgrade logic: risky high-score overrides evidence_only
113
+ // Matches PainDiagnosticGate.risky_high_score: isRisky && score >= 70 → admit
114
+ if (
115
+ result.decision === 'evidence_only' &&
116
+ options?.isRisky === true &&
117
+ score >= 70
118
+ ) {
119
+ result = {
120
+ ...result,
121
+ decision: 'admit',
122
+ reason: 'Risky high-score operation overrides evidence-only decision. Immediate diagnosis required.',
123
+ nextAction: 'create_diagnostic_task',
124
+ };
125
+ }
126
+
127
+ // PEAT-B1 upgrade logic: repeated failures override evidence_only
128
+ // Threshold: 4 consecutive failures (matches PainDiagnosticGate.repeatedFailure)
129
+ if (
130
+ result.decision === 'evidence_only' &&
131
+ options?.consecutiveErrors !== undefined &&
132
+ options.consecutiveErrors >= 4
133
+ ) {
134
+ result = {
135
+ ...result,
136
+ decision: 'admit',
137
+ reason: 'Repeated failures override evidence-only decision. Pattern suggests systemic issue requiring diagnosis.',
138
+ nextAction: 'create_diagnostic_task',
139
+ };
140
+ }
141
+
142
+ return result;
136
143
  }
137
144
 
138
145
  // ── High-Confidence Unsafe Action Detection ──────────────────────────────────
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Trigger Cooldown Tracker — PRI-363
3
+ *
4
+ * Manages cooldown state for trigger controller decisions.
5
+ *
6
+ * This is a plugin-layer concern because:
7
+ * - Core (trigger-controller) is stateless and pure
8
+ * - Cooldown state needs to persist across tool calls
9
+ * - The map is scoped to the plugin's lifecycle
10
+ *
11
+ * EP-05: Loop State Freshness — each check reads fresh state from the map.
12
+ * ERR-001: No `as` casts on map access.
13
+ * ERR-002: Every rejected decision includes reason + nextAction.
14
+ */
15
+
16
+ const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000; // 15 minutes
17
+
18
+ /**
19
+ * Episode key format: sessionId:source:errorHash
20
+ */
21
+ function buildEpisodeKey(
22
+ source: string,
23
+ sessionId: string | undefined,
24
+ errorHash: string | undefined,
25
+ ): string {
26
+ const sid = sessionId || 'unknown';
27
+ const hash = errorHash || 'no-hash';
28
+ return `${sid}:${source}:${hash}`;
29
+ }
30
+
31
+ /**
32
+ * Check whether cooldown is currently active for a given episode.
33
+ */
34
+ export function isCooldownActive(
35
+ source: string,
36
+ sessionId: string | undefined,
37
+ errorHash: string | undefined,
38
+ cooldownMap: ReadonlyMap<string, number>,
39
+ ): boolean {
40
+ const episodeKey = buildEpisodeKey(source, sessionId, errorHash);
41
+ const lastDiagnosedAt = cooldownMap.get(episodeKey);
42
+
43
+ if (lastDiagnosedAt === undefined) {
44
+ return false;
45
+ }
46
+
47
+ const now = Date.now();
48
+ return now - lastDiagnosedAt < DEFAULT_COOLDOWN_MS;
49
+ }
50
+
51
+ /**
52
+ * Mark an episode as diagnosed (set cooldown timestamp).
53
+ */
54
+ export function markEpisodeAsDiagnosed(
55
+ source: string,
56
+ sessionId: string | undefined,
57
+ errorHash: string | undefined,
58
+ cooldownMap: Map<string, number>,
59
+ ): void {
60
+ const episodeKey = buildEpisodeKey(source, sessionId, errorHash);
61
+ cooldownMap.set(episodeKey, Date.now());
62
+ }
63
+
64
+ /**
65
+ * Clear all cooldown state (for tests).
66
+ */
67
+ export function clearCooldownState(cooldownMap: Map<string, number>): void {
68
+ cooldownMap.clear();
69
+ }
70
+
71
+ /**
72
+ * Get the cooldown timestamp for a given episode (for tests).
73
+ */
74
+ export function getCooldownTimestamp(
75
+ source: string,
76
+ sessionId: string | undefined,
77
+ errorHash: string | undefined,
78
+ cooldownMap: ReadonlyMap<string, number>,
79
+ ): number | undefined {
80
+ const episodeKey = buildEpisodeKey(source, sessionId, errorHash);
81
+ return cooldownMap.get(episodeKey);
82
+ }
@@ -62,4 +62,16 @@ LLMs are highly sensitive to XML tags; this structure is designed to boost instr
62
62
  <must>Maintain extreme digital cleanliness. The project root is SACRED. Use strict `kebab-case` for all naming. Clean up all test scripts and debug artifacts after the task.</must>
63
63
  <forbidden>Creating arbitrary temporary files (e.g., `test.txt`, `temp.md`, `debug.log`) in the project root directory.</forbidden>
64
64
  </directive>
65
+
66
+ <directive id="T-09" name="DIVIDE_AND_CONQUER">
67
+ <trigger>When facing a complex task, multi-step change, or an operation that can be decomposed.</trigger>
68
+ <must>Break the task into smaller, manageable phases before execution. Execute one phase at a time and verify each phase's result before proceeding.</must>
69
+ <forbidden>Attempting to execute a large, complex change in a single step, or proceeding without a decomposition plan.</forbidden>
70
+ </directive>
71
+
72
+ <directive id="T-10" name="MEMORY_EXTERNALIZATION">
73
+ <trigger>When reaching a significant conclusion, making a decision, or planning next steps across sessions.</trigger>
74
+ <must>Write intermediate conclusions, decisions, and plans to persistent files (e.g., plan.md, scratchpad) so they survive context compression and session boundaries.</must>
75
+ <forbidden>Relying solely on conversation context to retain important state that will be lost when the context window shifts.</forbidden>
76
+ </directive>
65
77
  </thinking_os_core_directives>
@@ -62,4 +62,16 @@
62
62
  <must>保持极致的数字洁癖。项目根目录是神圣的。所有命名必须严格使用 `kebab-case`。任务结束后清理所有的测试脚本和 Debug 遗留物。</must>
63
63
  <forbidden>在项目根目录下随意创建临时文件(如 `test.txt`、`temp.md`、`debug.log`)。</forbidden>
64
64
  </directive>
65
+
66
+ <directive id="T-09" name="DIVIDE_AND_CONQUER">
67
+ <trigger>当面对复杂任务、多步骤变更或可分解的操作时。</trigger>
68
+ <must>在执行前将任务拆分为更小的、可管理的阶段。逐阶段执行,并在进入下一阶段前验证当前阶段的结果。</must>
69
+ <forbidden>试图在单一步骤中执行大型复杂变更,或在没有分解计划的情况下直接推进。</forbidden>
70
+ </directive>
71
+
72
+ <directive id="T-10" name="MEMORY_EXTERNALIZATION">
73
+ <trigger>当得出重要结论、做出决策或规划跨会话的后续步骤时。</trigger>
74
+ <must>将中间结论、决策和计划写入持久化文件(如 plan.md、scratchpad),使其在上下文压缩和会话边界后仍然可用。</must>
75
+ <forbidden>仅依赖对话上下文来保持重要状态,这些状态在上下文窗口切换时将丢失。</forbidden>
76
+ </directive>
65
77
  </thinking_os_core_directives>
@@ -7,7 +7,7 @@ import * as ioUtils from '../../src/utils/io.js';
7
7
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
8
8
  import { EventLogService } from '../../src/core/event-log.js';
9
9
  import { setInjectedProbationIds, clearSession } from '../../src/core/session-tracker.js';
10
- import { resetPainDiagnosticGateForTest } from '../../src/core/pain-diagnostic-gate.js';
10
+ import { resetTriggerCooldownForTest } from '../../src/hooks/after-tool-call-helpers.js';
11
11
  import { loadFeatureFlagFromConfig } from '../../src/core/pd-config-loader.js';
12
12
 
13
13
  vi.mock('fs');
@@ -140,12 +140,12 @@ describe('Post-Write Checks & Pain Hook', () => {
140
140
  mockEmitSync.mockReset();
141
141
  mockRecordProbationFeedback.mockReset();
142
142
  mockUpdatePrincipleValueMetrics.mockReset();
143
- vi.spyOn(WorkspaceContext, 'fromHookContext').mockReturnValue(mockWctx as any);
143
+ vi.spyOn(WorkspaceContext, 'fromHookContextExplicit').mockReturnValue(mockWctx as any);
144
144
  vi.spyOn(EventLogService, 'get').mockReturnValue(mockEventLog as any);
145
145
  clearSession('s-success');
146
146
  clearSession('s-low-value-failure');
147
147
  clearSession('s-repeated-failure');
148
- resetPainDiagnosticGateForTest();
148
+ resetTriggerCooldownForTest();
149
149
  });
150
150
 
151
151
  afterEach(() => {
@@ -158,7 +158,7 @@ describe('Post-Write Checks & Pain Hook', () => {
158
158
  handleAfterToolCall(mockEvent as any, mockCtx as any);
159
159
 
160
160
  // Should still create context
161
- expect(WorkspaceContext.fromHookContext).toHaveBeenCalled();
161
+ expect(WorkspaceContext.fromHookContextExplicit).toHaveBeenCalled();
162
162
  expect(fs.writeFileSync).not.toHaveBeenCalled();
163
163
  expect(mockEmitSync).not.toHaveBeenCalled();
164
164
  });
@@ -178,7 +178,7 @@ describe('Post-Write Checks & Pain Hook', () => {
178
178
 
179
179
  handleAfterToolCall(mockEvent as any, mockCtx as any, mockApi as any);
180
180
 
181
- expect(WorkspaceContext.fromHookContext).not.toHaveBeenCalled();
181
+ expect(WorkspaceContext.fromHookContextExplicit).not.toHaveBeenCalled();
182
182
  expect(mockEmitSync).not.toHaveBeenCalled();
183
183
  });
184
184
 
@@ -221,6 +221,13 @@ describe('Post-Write Checks & Pain Hook', () => {
221
221
  vi.mocked(ioUtils.isRisky).mockReturnValue(false);
222
222
  vi.mocked(fs.existsSync).mockReturnValue(false);
223
223
 
224
+ // PRI-363: trigger controller requires consecutiveErrors >= 4 for upgrade
225
+ handleAfterToolCall(mockEvent as any, mockCtx as any);
226
+ expect(mockEmitSync).not.toHaveBeenCalled();
227
+
228
+ handleAfterToolCall(mockEvent as any, mockCtx as any);
229
+ expect(mockEmitSync).not.toHaveBeenCalled();
230
+
224
231
  handleAfterToolCall(mockEvent as any, mockCtx as any);
225
232
  expect(mockEmitSync).not.toHaveBeenCalled();
226
233
 
@@ -231,7 +238,6 @@ describe('Post-Write Checks & Pain Hook', () => {
231
238
  data: expect.objectContaining({
232
239
  painType: 'tool_failure',
233
240
  source: 'write',
234
- reason: expect.stringContaining('diagnosticGate=high_gfi'),
235
241
  }),
236
242
  }));
237
243
  expect(mockWctx.trajectory.recordToolCall).toHaveBeenCalledWith(expect.objectContaining({
@@ -309,6 +315,8 @@ describe('Post-Write Checks & Pain Hook', () => {
309
315
  contextTags: ['write'],
310
316
  });
311
317
 
318
+ // PRI-363: risky high-score write triggers admission via trigger controller
319
+ // (isRisky=true + score >= 70 → risky_high_score upgrade → admit)
312
320
  handleAfterToolCall(mockEvent as any, mockCtx as any);
313
321
 
314
322
  expect(mockUpdatePrincipleValueMetrics).toHaveBeenCalledWith(
@@ -652,7 +660,7 @@ describe('PRI-326: evaluatePainAdmissionForToolCall', () => {
652
660
 
653
661
  beforeEach(() => {
654
662
  vi.clearAllMocks();
655
- resetPainDiagnosticGateForTest();
663
+ resetTriggerCooldownForTest();
656
664
  vi.mocked(loadFeatureFlagFromConfig).mockReturnValue({ enabled: false, source: 'test' });
657
665
  });
658
666
 
@@ -672,29 +680,27 @@ describe('PRI-326: evaluatePainAdmissionForToolCall', () => {
672
680
  expect(result.stage).toBe('not_applicable');
673
681
  });
674
682
 
675
- it('returns triage_evidence_only when feature flag on and tool_failure triage rejects', () => {
683
+ it('returns trigger_rejected when tool_failure triage rejects', () => {
676
684
  vi.mocked(loadFeatureFlagFromConfig).mockReturnValue({ enabled: true, source: 'test' });
677
685
 
678
686
  const result = evaluatePainAdmissionForToolCall(
679
687
  { toolName: 'write' } as any, baseObservation, baseOutcome, undefined, undefined, 's1', workspaceDir, mockConfig
680
688
  );
681
- expect(result.stage).toBe('triage_evidence_only');
689
+ expect(result.stage).toBe('trigger_rejected');
682
690
  expect(result.admitted).toBe(false);
683
691
  expect(result.reason).toBeTruthy();
684
692
  });
685
693
 
686
- it('returns gate_admitted when consecutive errors exceed repeatedFailure threshold', () => {
694
+ it('returns trigger_admitted when consecutive errors exceed repeatedFailure threshold', () => {
687
695
  vi.mocked(loadFeatureFlagFromConfig).mockReturnValue({ enabled: false, source: 'test' });
688
- // consecutiveErrors=5 >= default repeatedFailure threshold of 4 → gate admits via repeated_failure
696
+ // consecutiveErrors=5 >= default repeatedFailure threshold of 4 → trigger admits via repeated_failure
689
697
  const highConsecutiveState = { currentGfi: 0, consecutiveErrors: 5, lastErrorHash: 'abc123' } as any;
690
698
 
691
699
  const result = evaluatePainAdmissionForToolCall(
692
700
  { toolName: 'write' } as any, baseObservation, baseOutcome, highConsecutiveState, undefined, 's-gate-admitted-test', workspaceDir, mockConfig
693
701
  );
694
- expect(result.stage).toBe('gate_admitted');
702
+ expect(result.stage).toBe('trigger_admitted');
695
703
  expect(result.admitted).toBe(true);
696
- expect(result.gateResult?.shouldDiagnose).toBe(true);
697
- expect(result.gateResult?.reason).toBe('repeated_failure');
698
704
  });
699
705
 
700
706
  it('includes reason and detail in every decision', () => {