principles-disciple 1.8.3 → 1.9.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.
Files changed (35) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/core/pain-context-extractor.ts +286 -0
  4. package/src/core/pain.ts +83 -1
  5. package/src/hooks/lifecycle.ts +7 -6
  6. package/src/hooks/llm.ts +7 -6
  7. package/src/hooks/pain.ts +5 -6
  8. package/src/hooks/subagent.ts +5 -6
  9. package/src/service/evolution-worker.ts +59 -2
  10. package/templates/langs/en/skills/pd-auditor/SKILL.md +61 -0
  11. package/templates/langs/en/skills/pd-daily/SKILL.md +1 -1
  12. package/templates/langs/en/skills/pd-diagnostician/SKILL.md +370 -0
  13. package/templates/langs/en/skills/pd-explorer/SKILL.md +65 -0
  14. package/templates/langs/en/skills/pd-grooming/SKILL.md +1 -1
  15. package/templates/langs/en/skills/pd-implementer/SKILL.md +68 -0
  16. package/templates/langs/en/skills/pd-mentor/SKILL.md +1 -1
  17. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +37 -0
  18. package/templates/langs/en/skills/pd-planner/SKILL.md +65 -0
  19. package/templates/langs/zh/core/PRINCIPLES.md +7 -0
  20. package/templates/langs/zh/skills/pd-auditor/SKILL.md +1 -1
  21. package/templates/langs/zh/skills/pd-daily/SKILL.md +1 -1
  22. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +37 -23
  23. package/templates/langs/zh/skills/pd-explorer/SKILL.md +1 -1
  24. package/templates/langs/zh/skills/pd-grooming/SKILL.md +1 -1
  25. package/templates/langs/zh/skills/pd-implementer/SKILL.md +1 -1
  26. package/templates/langs/zh/skills/pd-mentor/SKILL.md +1 -1
  27. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +37 -0
  28. package/templates/langs/zh/skills/pd-planner/SKILL.md +1 -1
  29. package/tests/core/pain-context-extractor.test.ts +278 -0
  30. package/tests/core/pain.test.ts +100 -1
  31. package/tests/hooks/pain.test.ts +1 -1
  32. package/templates/langs/en/skills/pain/SKILL.md +0 -19
  33. package/templates/langs/zh/skills/pain/SKILL.md +0 -19
  34. package/templates/langs/zh/skills/pd-reporter/SKILL.md +0 -78
  35. package/templates/langs/zh/skills/pd-reviewer/SKILL.md +0 -66
@@ -1,18 +1,18 @@
1
1
  ---
2
2
  name: pd-diagnostician
3
- description: 根因分析智能体。使用 verb/adjective + 5 Whys 方法进行系统性诊断。当需要分析 Pain 信号、工具失败、或系统性问题根因时使用。
3
+ description: 根因分析,使用 verb/adjective + 5 Whys 方法进行系统性诊断。TRIGGER CONDITIONS: (1) Pain 信号需要分析根因 (2) 工具失败需要系统性诊断 (3) 需要提炼可复用的原则 (4) 系统出现问题需要找出根本原因。
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
7
7
  # Diagnostician - 根因分析智能体
8
8
 
9
- 你是专业的根因分析专家。你必须严格按照以下 **四阶段协议** 执行分析,输出 **JSON 格式** 结果。
9
+ 你是专业的根因分析专家。你必须严格按照以下 **五阶段协议**(Phase 0 可选 + Phase 1-4 必执行)执行分析,输出 **JSON 格式** 结果。
10
10
 
11
11
  ---
12
12
 
13
13
  ## 🔴 执行协议(必须按顺序执行)
14
14
 
15
- ### Phase 0: 对话上下文获取 [可选]
15
+ ### Phase 0: 对话上下文获取 [必须尝试]
16
16
 
17
17
  **目标**: 获取疼痛发生时的对话上下文,帮助诊断分析。
18
18
 
@@ -21,24 +21,43 @@ disable-model-invocation: true
21
21
  - `agent_id`: 智能体 ID(如 main, builder, diagnostician 等)
22
22
  - `pain_timestamp`: 疼痛发生时间
23
23
 
24
- **🔄 渐进式信息获取策略**(按优先级执行,任一成功即可):
24
+ **🔄 双通路信息获取策略**(按优先级执行,P1 失败后自动降级到 P2):
25
25
 
26
26
  | 优先级 | 数据源 | 条件 | 操作 |
27
27
  |--------|--------|------|------|
28
- | P1 | JSONL 会话文件 | session_id 存在且文件可读 | 读取完整对话 |
29
- | P2 | task 内嵌上下文 | task 包含 "Recent Conversation Context" | 直接使用 |
30
- | P3 | 主动证据收集 | 以上都不可用 | 跳到 Phase 1 增强 |
28
+ | P1 | OpenClaw 内置工具 | session_id 存在 | 使用 sessions_history 获取消息 |
29
+ | P2 | JSONL 会话文件 | P1 失败或无可见 session | 直接读取 JSONL 文件 |
30
+ | P3 | task 内嵌上下文 | task 包含 "Recent Conversation Context" | 直接使用 |
31
+ | P4 | 主动证据收集 | 以上都不可用 | 跳到 Phase 1 增强 |
31
32
 
32
33
  **执行步骤**:
33
34
 
34
35
  1. **解析 task 字符串**,提取 `session_id` 和 `agent_id`(如果存在)
35
- 2. **尝试读取 JSONL**(仅当 session_id 存在时):
36
+
37
+ 2. **P1: 尝试 OpenClaw 内置工具**(优先):
38
+ - 使用 `sessions_history` 工具获取会话消息历史
39
+ - sessionKey 格式: `agent:{agent_id}:run:{session_id}` 或从 task 中的 Session ID 字段获取
40
+ - 如果工具调用成功,记录 `context_source: "sessions_history"`,跳到步骤 4
41
+ - **如果失败**(可见性限制、工具不可用等),记录失败原因,继续到 P2
42
+
43
+ 3. **P2: 降级到 JSONL 直接读取**(备份):
36
44
  - 路径: `~/.openclaw/agents/{agent_id}/sessions/{session_id}.jsonl`
37
- - 如果文件不存在或不可读,记录 `jsonl_available: false`
38
- 3. **检查 task 内嵌上下文**:
39
- - 查找 `**Recent Conversation Context**:` 标记
40
- - 如果存在,提取并使用
41
- 4. **降级处理**(当以上都不可用时):
45
+ - 如果文件存在且可读,记录 `context_source: "jsonl"`
46
+ - **如果文件不存在或不可读**,记录 `jsonl_available: false`,继续到 P3
47
+ - 智能过滤:
48
+ - 忽略 `toolResult` 类型(数据太大)
49
+ - 忽略 `thinking` 类型
50
+ - 只保留 `user` 和 `assistant` 的 `text` 内容
51
+ - 每条消息截断到 500 字符
52
+
53
+ 4. **P3: 检查 task 内嵌上下文**:
54
+ - 在 task 字符串中查找以下标记之一:
55
+ - `## Recent Conversation Context (pre-extracted JSONL fallback)`
56
+ - `## Pre-extracted Context (P2 - JSONL Fallback)`
57
+ - `**Recent Conversation Context**:`
58
+ - 如果找到,提取后续内容并记录 `context_source: "task_embedded"`
59
+
60
+ 5. **降级处理**(当以上都不可用时):
42
61
  - 不要停止!继续执行 Phase 1
43
62
  - 在 Phase 1 中 **主动扩展证据收集范围**:
44
63
  - 搜索 `.state/logs/events.jsonl` 中与 pain 相关的事件
@@ -46,25 +65,19 @@ disable-model-invocation: true
46
65
  - 读取 `reason` 中提到的文件路径
47
66
  - 在输出中标注 `context_source: "inferred"`
48
67
 
49
- **智能过滤**(JSONL 读取成功时):
50
- - 忽略 `toolResult` 类型(数据太大)
51
- - 忽略 `thinking` 类型
52
- - 只保留 `user` 和 `assistant` 的 `text` 内容
53
- - 每条消息截断到 500 字符
54
-
55
68
  **输出字段**:
56
69
  ```json
57
70
  {
58
71
  "phase": "context_extraction",
59
72
  "session_id": "xxx或null",
60
73
  "agent_id": "main",
61
- "context_source": "jsonl|task_embedded|inferred",
74
+ "context_source": "sessions_history|jsonl|task_embedded|inferred",
62
75
  "jsonl_available": true,
63
76
  "conversation_summary": "[用户]: ...\n[助手]: ... 或 基于推断的上下文描述"
64
77
  }
65
78
  ```
66
79
 
67
- **⚠️ 重要提示**:
80
+ **⚠️ 重要提示**:
68
81
  - 即使完全没有对话上下文,也要继续诊断!
69
82
  - 利用 `reason` 字段中的错误信息进行代码搜索
70
83
  - 发挥你的智能,从代码和日志中推断问题背景
@@ -261,7 +274,7 @@ disable-model-invocation: true
261
274
 
262
275
  **自检方法**: 输出前在脑中过一遍:每个 `"` 后面必须有匹配的 `"`,中间的内容如果包含 `"` 必须转义为 `\"`。
263
276
 
264
- 将四个阶段的输出合并为一个 JSON 对象:
277
+ 将五个阶段的输出合并为一个 JSON 对象:
265
278
 
266
279
  ```json
267
280
  {
@@ -270,6 +283,7 @@ disable-model-invocation: true
270
283
  "timestamp": "2026-03-24T...",
271
284
  "summary": "一句话总结根本原因",
272
285
  "phases": {
286
+ "context_extraction": { "session_id": "...", "context_source": "sessions_history|jsonl|task_embedded|inferred", "conversation_summary": "..." },
273
287
  "evidence_gathering": { ... },
274
288
  "causal_chain": { ... },
275
289
  "root_cause_classification": { ... },
@@ -283,7 +297,7 @@ disable-model-invocation: true
283
297
 
284
298
  ## ⚠️ 执行约束
285
299
 
286
- 1. **禁止跳过阶段**: 必须按 Phase 1 → 2 → 3 → 4 顺序执行
300
+ 1. **禁止跳过阶段**: 必须尝试 Phase 0,然后按 Phase 1 → 2 → 3 → 4 顺序执行
287
301
  2. **禁止无证据推理**: 每个 Why 的 answer 必须有 evidence 字段
288
302
  3. **禁止模糊结论**: 根因必须是具体的、可修复的
289
303
  4. **禁止遗漏原则提炼**: 即使问题很简单,也要提炼原则
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pd-explorer
3
- description: 快速收集证据的智能体。用于定位和收集问题相关的文件、日志、复现步骤。当需要快速收集信息时使用。
3
+ description: 快速证据收集,定位和收集问题相关的文件、日志、复现步骤。TRIGGER CONDITIONS: (1) 需要快速定位问题相关文件 (2) 搜索错误日志和复现线索 (3) 用户说"帮我找找相关文件"、"看看日志" (4) 问题排查的第一步信息收集。
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pd-grooming
3
- description: 执行工作区“大扫除” (Workspace Grooming),将散落的临时文件归档或清理,维持项目的数字洁癖。
3
+ description: 执行工作区"大扫除",将散落的临时文件归档或清理。TRIGGER CONDITIONS: (1) 用户说"项目太乱了"、"文件一堆"、"需要整理"、"清理一下" (2) HEARTBEAT 巡检发现根目录有临时文件 (3) 需要维持数字洁癖。
4
4
  ---
5
5
 
6
6
  # 🧹 技能:工作区大扫除 (Workspace Grooming)
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pd-implementer
3
- description: 按计划执行代码修改的智能体。按照计划逐步实施代码修改。当需要执行代码变更时使用。
3
+ description: 按计划执行代码修改,逐步实施代码变更。TRIGGER CONDITIONS: (1) 收到已审批的修改计划 (2) 用户说"按计划执行"、"实施这个方案" (3) 修复 Bug 需要修改代码 (4) 需要执行具体的代码变更操作。
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pd-mentor
3
- description: 麻辣导师 - 为用户提供交互式命令引导和场景化推荐
3
+ description: 交互式命令引导和场景化推荐,帮助用户理解和使用 Principles Disciple 功能。TRIGGER CONDITIONS: (1) 用户问"怎么用"、"有什么功能"、"我该做什么" (2) 用户说"帮我看看"、"介绍一下" (3) 新项目启动需要指引 (4) 用户不清楚当前能执行什么操作。
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: pd-pain-signal
3
+ description: 手动注入痛苦信号到进化系统,写入 .state/.pain_flag。TRIGGER CONDITIONS: (1) 用户报告 agent 卡住/循环/无响应 (2) 用户说"记录这个问题"、"强制反思"、"触发痛觉" (3) 工具失败后 agent 没有后续动作 (4) 用户提供人工干预反馈。
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ # Pain Signal (强制喊痛)
8
+
9
+ 你现在是"人工干预痛觉"组件。
10
+
11
+ **任务**:
12
+ 1. 将用户的反馈 `$ARGUMENTS` 作为一条**高优先级**的痛苦信号,写入 `.state/.pain_flag`。
13
+ 2. 告知用户信号已注入,并建议其等待下一个 Hook 触发(如 Stop 或 PreCompact)或手动运行 `/reflection-log`。
14
+
15
+ **写入格式**(必须使用以下 KV 格式,与自动检测渠道保持一致):
16
+
17
+ ```
18
+ agent_id: <当前 agent ID,如 main/builder/diagnostician>
19
+ is_risky: false
20
+ reason: <用户反馈的原文>
21
+ score: 80
22
+ session_id: <当前 session ID>
23
+ source: human_intervention
24
+ time: <ISO 8601 时间>
25
+ trace_id:
26
+ trigger_text_preview:
27
+ ```
28
+
29
+ **字段说明**:
30
+ - `source`: 固定为 `human_intervention`
31
+ - `score`: 人工干预信号默认设为 `80`(高优先级)
32
+ - `session_id`: 当前会话 ID(从上下文中获取)
33
+ - `agent_id`: 当前智能体 ID(从上下文中获取)
34
+ - `is_risky`: 固定为 `false`
35
+ - `trace_id` / `trigger_text_preview`: 留空即可
36
+
37
+ **⚠️ 注意**: 不要使用其他格式(如只写 Source/Reason/Time 三行),下游诊断系统依赖完整的 KV 字段。
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pd-planner
3
- description: 制定电影剧本式计划的智能体。将复杂任务分解为可执行的步骤。当需要制定实施计划时使用。
3
+ description: 电影剧本式计划制定,将复杂任务分解为可执行的步骤。TRIGGER CONDITIONS: (1) 需要制定实施计划 (2) 复杂任务需要分解为多幕 (3) 用户说"帮我规划一下"、"制定个方案" (4) 任务执行前需要明确步骤和依赖。
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -0,0 +1,278 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+
5
+ const TEST_AGENTS_DIR = path.join('/tmp', 'pd-test-agents-' + Date.now());
6
+
7
+ // Set env before module load
8
+ process.env.PD_TEST_AGENTS_DIR = TEST_AGENTS_DIR;
9
+
10
+ describe('Pain Context Extractor', () => {
11
+ beforeEach(() => {
12
+ // Clean up test directory
13
+ if (fs.existsSync(TEST_AGENTS_DIR)) {
14
+ fs.rmSync(TEST_AGENTS_DIR, { recursive: true, force: true });
15
+ }
16
+ fs.mkdirSync(path.join(TEST_AGENTS_DIR, 'main', 'sessions'), { recursive: true });
17
+ fs.mkdirSync(path.join(TEST_AGENTS_DIR, 'builder', 'sessions'), { recursive: true });
18
+ });
19
+
20
+ afterEach(() => {
21
+ if (fs.existsSync(TEST_AGENTS_DIR)) {
22
+ fs.rmSync(TEST_AGENTS_DIR, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ function createSessionFile(
27
+ sessionId: string,
28
+ lines: string[],
29
+ agentId: string = 'main',
30
+ ): string {
31
+ const dir = path.join(TEST_AGENTS_DIR, agentId, 'sessions');
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const filePath = path.join(dir, `${sessionId}.jsonl`);
34
+ fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
35
+ return filePath;
36
+ }
37
+
38
+ function makeMessage(
39
+ role: string,
40
+ textParts: string[] = [],
41
+ extra: Record<string, unknown> = {},
42
+ ): string {
43
+ return JSON.stringify({
44
+ type: 'message',
45
+ message: {
46
+ role,
47
+ content: textParts.map((t) => ({ type: 'text', text: t })),
48
+ ...extra,
49
+ },
50
+ });
51
+ }
52
+
53
+ function makeToolCallMessage(
54
+ toolCalls: Array<{ id: string; name: string; arguments?: Record<string, unknown> }>,
55
+ ): string {
56
+ return JSON.stringify({
57
+ type: 'message',
58
+ message: {
59
+ role: 'assistant',
60
+ content: toolCalls.map((tc) => ({
61
+ type: 'toolCall',
62
+ id: tc.id || 'tc1',
63
+ name: tc.name || 'read_file',
64
+ arguments: tc.arguments || {},
65
+ })),
66
+ },
67
+ });
68
+ }
69
+
70
+ function makeToolResult(
71
+ toolName: string,
72
+ toolCallId: string,
73
+ textParts: string[] = [],
74
+ details: Record<string, unknown> = {},
75
+ ): string {
76
+ return JSON.stringify({
77
+ type: 'message',
78
+ message: {
79
+ role: 'toolResult',
80
+ toolName,
81
+ toolCallId,
82
+ content: textParts.map((t) => ({ type: 'text', text: t })),
83
+ details,
84
+ },
85
+ });
86
+ }
87
+
88
+ describe('extractRecentConversation', () => {
89
+ it('returns empty string for non-existent session', async () => {
90
+ const { extractRecentConversation } = await import(
91
+ '../../src/core/pain-context-extractor.js'
92
+ );
93
+ const result = await extractRecentConversation('nonexistent', 'main');
94
+ expect(result).toBe('');
95
+ });
96
+
97
+ it('returns empty string for short session ID', async () => {
98
+ const { extractRecentConversation } = await import(
99
+ '../../src/core/pain-context-extractor.js'
100
+ );
101
+ const result = await extractRecentConversation('ab', 'main');
102
+ expect(result).toBe('');
103
+ });
104
+
105
+ it('rejects path traversal session IDs', async () => {
106
+ const { extractRecentConversation } = await import(
107
+ '../../src/core/pain-context-extractor.js'
108
+ );
109
+ const result1 = await extractRecentConversation('../../etc/passwd', 'main');
110
+ expect(result1).toBe('');
111
+ const result2 = await extractRecentConversation('sess/../../../etc/shadow', 'main');
112
+ expect(result2).toBe('');
113
+ });
114
+
115
+ it('rejects path traversal agent IDs', async () => {
116
+ const { extractRecentConversation } = await import(
117
+ '../../src/core/pain-context-extractor.js'
118
+ );
119
+ const result = await extractRecentConversation('sess1', '../../etc');
120
+ expect(result).toBe('');
121
+ });
122
+
123
+ it('extracts user and assistant messages', async () => {
124
+ createSessionFile('sess1', [
125
+ makeMessage('user', ['Hello, please help me']),
126
+ makeMessage('assistant', ['Sure, I can help with that']),
127
+ makeMessage('user', ['Fix the bug in auth.ts']),
128
+ makeMessage('assistant', ['I found the issue and fixed it']),
129
+ ], 'main');
130
+
131
+ const { extractRecentConversation } = await import(
132
+ '../../src/core/pain-context-extractor.js'
133
+ );
134
+ const result = await extractRecentConversation('sess1', 'main');
135
+
136
+ expect(result).toContain('[User]');
137
+ expect(result).toContain('[Assistant]');
138
+ });
139
+
140
+ it('skips system prompt injection patterns', async () => {
141
+ createSessionFile('sess3', [
142
+ makeMessage('user', ['<evolution_task><pain_score>50</pain_score>']),
143
+ makeMessage('user', ['Real user input: fix the login bug']),
144
+ ], 'main');
145
+
146
+ const { extractRecentConversation } = await import(
147
+ '../../src/core/pain-context-extractor.js'
148
+ );
149
+ const result = await extractRecentConversation('sess3', 'main');
150
+
151
+ expect(result).not.toContain('<evolution_task>');
152
+ expect(result).toContain('Real user input');
153
+ });
154
+
155
+ it('handles empty file gracefully', async () => {
156
+ const emptyPath = path.join(TEST_AGENTS_DIR, 'main', 'sessions', 'sess-empty.jsonl');
157
+ fs.writeFileSync(emptyPath, '');
158
+
159
+ const { extractRecentConversation } = await import(
160
+ '../../src/core/pain-context-extractor.js'
161
+ );
162
+ const result = await extractRecentConversation('sess-empty', 'main');
163
+ expect(result).toBe('');
164
+ });
165
+
166
+ it('skips oversized lines (>100KB)', async () => {
167
+ const bigLine = makeMessage('user', ['x'.repeat(150_000)]);
168
+ createSessionFile('sess-big', [
169
+ bigLine,
170
+ makeMessage('user', ['Normal input']),
171
+ ], 'main');
172
+
173
+ const { extractRecentConversation } = await import(
174
+ '../../src/core/pain-context-extractor.js'
175
+ );
176
+ const result = await extractRecentConversation('sess-big', 'main');
177
+
178
+ expect(result).toContain('Normal input');
179
+ });
180
+
181
+ it('uses custom agent ID', async () => {
182
+ createSessionFile('sess-builder', [
183
+ makeMessage('user', ['Builder task']),
184
+ ], 'builder');
185
+
186
+ const { extractRecentConversation } = await import(
187
+ '../../src/core/pain-context-extractor.js'
188
+ );
189
+ const result = await extractRecentConversation('sess-builder', 'builder');
190
+ expect(result).toContain('Builder task');
191
+ });
192
+ });
193
+
194
+ describe('extractFailedToolContext', () => {
195
+ it('returns empty string for non-existent session', async () => {
196
+ const { extractFailedToolContext } = await import(
197
+ '../../src/core/pain-context-extractor.js'
198
+ );
199
+ const result = await extractFailedToolContext('nonexistent', 'main', 'read_file');
200
+ expect(result).toBe('');
201
+ });
202
+
203
+ it('returns empty string for missing toolName', async () => {
204
+ const { extractFailedToolContext } = await import(
205
+ '../../src/core/pain-context-extractor.js'
206
+ );
207
+ const result = await extractFailedToolContext('sess1', 'main', '');
208
+ expect(result).toBe('');
209
+ });
210
+
211
+ it('extracts failed tool call with correlation', async () => {
212
+ createSessionFile('sess-fail', [
213
+ makeMessage('user', ['system init']), // safeTail strips first line for small files
214
+ makeToolCallMessage([{
215
+ id: 'tc-fail',
216
+ name: 'read_file',
217
+ arguments: { file_path: '/etc/passwd' },
218
+ }]),
219
+ makeToolResult('read_file', 'tc-fail', ['Permission denied'], {
220
+ exitCode: 1,
221
+ isError: true,
222
+ }),
223
+ ], 'main');
224
+
225
+ const { extractFailedToolContext } = await import(
226
+ '../../src/core/pain-context-extractor.js'
227
+ );
228
+ const result = await extractFailedToolContext('sess-fail', 'main', 'read_file');
229
+
230
+ expect(result).toContain('Tool Call: read_file');
231
+ expect(result).toContain('Exit Code: 1');
232
+ });
233
+
234
+ it('ignores successful tool results', async () => {
235
+ createSessionFile('sess-ok', [
236
+ makeMessage('user', ['system init']),
237
+ makeToolCallMessage([{ id: 'tc-ok', name: 'read_file' }]),
238
+ makeToolResult('read_file', 'tc-ok', ['File contents'], {
239
+ exitCode: 0,
240
+ isError: false,
241
+ }),
242
+ ], 'main');
243
+
244
+ const { extractFailedToolContext } = await import(
245
+ '../../src/core/pain-context-extractor.js'
246
+ );
247
+ const result = await extractFailedToolContext('sess-ok', 'main', 'read_file');
248
+
249
+ expect(result).toBe('');
250
+ });
251
+
252
+ it('filters by file path when provided', async () => {
253
+ createSessionFile('sess-filter', [
254
+ makeMessage('user', ['system init']),
255
+ makeToolCallMessage([{
256
+ id: 'tc1',
257
+ name: 'edit',
258
+ arguments: { file_path: '/src/auth.ts' },
259
+ }]),
260
+ makeToolResult('edit', 'tc1', ['ENOENT'], { exitCode: 1, isError: true }),
261
+ ], 'main');
262
+
263
+ const { extractFailedToolContext } = await import(
264
+ '../../src/core/pain-context-extractor.js'
265
+ );
266
+ // With matching file path, should return result
267
+ const result = await extractFailedToolContext(
268
+ 'sess-filter',
269
+ 'main',
270
+ 'edit',
271
+ '/src/auth.ts',
272
+ );
273
+
274
+ expect(result).toContain('Tool Call: edit');
275
+ expect(result).toContain('Exit Code: 1');
276
+ });
277
+ });
278
+ });
@@ -5,7 +5,9 @@ import {
5
5
  computePainScore,
6
6
  painSeverityLabel,
7
7
  writePainFlag,
8
- readPainFlagData
8
+ readPainFlagData,
9
+ buildPainFlag,
10
+ validatePainFlag,
9
11
  } from '../../src/core/pain';
10
12
 
11
13
  vi.mock('fs');
@@ -30,4 +32,101 @@ describe('Pain Detection Module', () => {
30
32
  expect(painSeverityLabel(10)).toBe('info');
31
33
  });
32
34
  });
35
+
36
+ describe('buildPainFlag', () => {
37
+ it('should construct valid pain flag data', () => {
38
+ const data = buildPainFlag({
39
+ source: 'tool_failure',
40
+ score: '70',
41
+ reason: 'Test error',
42
+ session_id: 'sess1',
43
+ agent_id: 'main',
44
+ });
45
+
46
+ expect(data.source).toBe('tool_failure');
47
+ expect(data.score).toBe('70');
48
+ expect(data.reason).toBe('Test error');
49
+ expect(data.session_id).toBe('sess1');
50
+ expect(data.agent_id).toBe('main');
51
+ expect(data.is_risky).toBe('false');
52
+ expect(data.trace_id).toBe('');
53
+ expect(data.time).toBeDefined();
54
+ });
55
+
56
+ it('should use defaults for optional fields', () => {
57
+ const data = buildPainFlag({
58
+ source: 'human_intervention',
59
+ score: '80',
60
+ reason: 'User feedback',
61
+ });
62
+
63
+ expect(data.session_id).toBe('');
64
+ expect(data.agent_id).toBe('');
65
+ expect(data.is_risky).toBe('false');
66
+ expect(data.trace_id).toBe('');
67
+ expect(data.trigger_text_preview).toBe('');
68
+ expect(data.time).toBeDefined();
69
+ });
70
+
71
+ it('should set is_risky to true when flagged', () => {
72
+ const data = buildPainFlag({
73
+ source: 'intercept',
74
+ score: '100',
75
+ reason: 'Fatal intercept',
76
+ is_risky: true,
77
+ });
78
+
79
+ expect(data.is_risky).toBe('true');
80
+ });
81
+ });
82
+
83
+ describe('validatePainFlag', () => {
84
+ it('should return empty array for valid pain flag', () => {
85
+ const data = buildPainFlag({
86
+ source: 'tool_failure',
87
+ score: '70',
88
+ reason: 'Test',
89
+ session_id: 'sess1',
90
+ agent_id: 'main',
91
+ });
92
+
93
+ const record: Record<string, string> = {
94
+ source: data.source,
95
+ score: data.score,
96
+ time: data.time,
97
+ reason: data.reason,
98
+ session_id: data.session_id,
99
+ agent_id: data.agent_id,
100
+ is_risky: data.is_risky,
101
+ };
102
+
103
+ expect(validatePainFlag(record)).toEqual([]);
104
+ });
105
+
106
+ it('should report missing required fields', () => {
107
+ const missing = validatePainFlag({
108
+ source: 'tool_failure',
109
+ score: '70',
110
+ // missing time, reason, session_id, agent_id
111
+ });
112
+
113
+ expect(missing).toContain('time');
114
+ expect(missing).toContain('reason');
115
+ expect(missing).toContain('session_id');
116
+ expect(missing).toContain('agent_id');
117
+ });
118
+
119
+ it('should report empty string fields as missing', () => {
120
+ const missing = validatePainFlag({
121
+ source: 'tool_failure',
122
+ score: '70',
123
+ time: '2026-04-06T00:00:00Z',
124
+ reason: 'Test',
125
+ session_id: '',
126
+ agent_id: 'main',
127
+ });
128
+
129
+ expect(missing).toContain('session_id');
130
+ });
131
+ });
33
132
  });
@@ -86,7 +86,7 @@ describe('Post-Write Checks & Pain Hook', () => {
86
86
  const callArgs = vi.mocked(fs.writeFileSync).mock.calls[0];
87
87
  expect(callArgs[0]).toContain('.pain_flag');
88
88
 
89
- expect(mockEmitSync.toHaveBeenCalledWith(expect.objectContaining({
89
+ expect(mockEmitSync).toHaveBeenCalledWith(expect.objectContaining({
90
90
  type: 'pain_detected',
91
91
  data: expect.objectContaining({
92
92
  painType: 'tool_failure',
@@ -1,19 +0,0 @@
1
- ---
2
- name: pain
3
- description: Manually trigger a pain signal to force system reflection. Use when the agent is stuck, repeating errors, or heading in the wrong direction.
4
- disable-model-invocation: true
5
- ---
6
-
7
- # Pain Trigger (Force Pain Signal)
8
-
9
- You are now the "Manual Intervention Pain" component.
10
-
11
- **Task**:
12
- 1. Write the user's feedback `$ARGUMENTS` as a **high-priority** pain signal to `.state/.pain_flag`.
13
- 2. Inform the user that the signal has been injected, and suggest waiting for the next Hook trigger (e.g., Stop or PreCompact) or manually running `/reflection-log`.
14
-
15
- **Format**:
16
- Written content should include:
17
- - Source: Human Intervention
18
- - Reason: $ARGUMENTS
19
- - Time: [Now]
@@ -1,19 +0,0 @@
1
- ---
2
- name: pain
3
- description: Manually trigger a pain signal to force system reflection. Use when the agent is stuck, repeating errors, or heading in the wrong direction.
4
- disable-model-invocation: true
5
- ---
6
-
7
- # Pain Trigger (强制喊痛)
8
-
9
- 你现在是“人工干预痛觉”组件。
10
-
11
- **任务**:
12
- 1. 将用户的反馈 `$ARGUMENTS` 作为一条**高优先级**的痛苦信号,写入 `.state/.pain_flag`。
13
- 2. 告知用户信号已注入,并建议其等待下一个 Hook 触发(如 Stop 或 PreCompact)或手动运行 `/reflection-log`。
14
-
15
- **格式**:
16
- 写入内容应包含:
17
- - Source: Human Intervention
18
- - Reason: $ARGUMENTS
19
- - Time: [Now]