principles-disciple 1.27.0 → 1.28.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.
@@ -356,7 +356,8 @@ function isSessionAtOrBeforeTriggerTime(
356
356
 
357
357
  function buildFallbackNocturnalSnapshot(
358
358
  sleepTask: EvolutionQueueItem,
359
- extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null
359
+ extractor?: ReturnType<typeof createNocturnalTrajectoryExtractor> | null,
360
+ logger?: { warn?: (message: string) => void }
360
361
  ): NocturnalSessionSnapshot | null {
361
362
  const painContext = sleepTask.recentPainContext;
362
363
  if (!painContext) {
@@ -390,8 +391,10 @@ function buildFallbackNocturnalSnapshot(
390
391
  totalGateBlocks: match.gateBlockCount,
391
392
  };
392
393
  }
393
- } catch {
394
- // Best effortnon-fatal
394
+ } catch (err) {
395
+ // #260: Log extraction failures silent swallowing makes debugging impossible
396
+ // and can mask systemic trajectory DB issues.
397
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to extract real stats for session ${painContext.mostRecent?.sessionId} (falling back to zeros): ${String(err)}`);
395
398
  }
396
399
  }
397
400
 
@@ -590,63 +593,154 @@ export function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext
590
593
  return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
591
594
  }
592
595
 
596
+ /**
597
+ * Build a dedup key from pain context.
598
+ * Returns null when no pain context is available (bypasses dedup).
599
+ */
600
+ function buildPainSourceKey(
601
+ painCtx: ReturnType<typeof readRecentPainContext>,
602
+ ): string | null {
603
+ if (!painCtx.mostRecent) return null;
604
+ return `${painCtx.mostRecent.source}::${painCtx.mostRecent.reason?.slice(0, 50) ?? ''}`;
605
+ }
606
+
607
+ /**
608
+ * Check whether a similar sleep_reflection task completed recently.
609
+ * Phase 3c: Prevents redundant reflections of the same underlying issue.
610
+ */
611
+ function hasRecentSimilarReflection(
612
+ queue: EvolutionQueueItem[],
613
+ painSourceKey: string,
614
+ now: number,
615
+ ): EvolutionQueueItem | null {
616
+ const DEDUP_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours
617
+ return queue.find((t) => {
618
+ if (t.taskKind !== 'sleep_reflection') return false;
619
+ // Only match completed tasks (exclude failed to allow retries)
620
+ if (t.status !== 'completed') return false;
621
+ if (!t.completed_at) return false;
622
+ const age = now - new Date(t.completed_at).getTime();
623
+ if (age > DEDUP_WINDOW_MS) return false;
624
+ const taskPainKey = buildPainSourceKey(t.recentPainContext ?? { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 });
625
+ // If either side has no pain context, they don't match
626
+ if (!taskPainKey) return false;
627
+ return taskPainKey === painSourceKey;
628
+ }) ?? null;
629
+ }
630
+
631
+ /**
632
+ * Check whether a specific task kind has a pending or in-progress entry.
633
+ */
634
+ function hasPendingTask(queue: EvolutionQueueItem[], taskKind: string): boolean {
635
+ return queue.some(
636
+ (t) => t.taskKind === taskKind && (t.status === 'pending' || t.status === 'in_progress'),
637
+ );
638
+ }
639
+
640
+ /**
641
+ * Decide whether to skip enqueuing due to a recent similar reflection.
642
+ * Returns true if skipped (with log), false if should proceed.
643
+ */
644
+ function shouldSkipForDedup(
645
+ queue: EvolutionQueueItem[],
646
+ wctx: WorkspaceContext,
647
+ logger: PluginLogger,
648
+ ): boolean {
649
+ const recentPainContext = readRecentPainContext(wctx);
650
+ const painSourceKey = buildPainSourceKey(recentPainContext);
651
+
652
+ // Bypass dedup when there is no pain context — general idle reflections
653
+ // should not be throttled by the 'no_pain_context' sentinel.
654
+ if (!painSourceKey) return false;
655
+
656
+ const now = Date.now();
657
+ const recentSimilarReflection = hasRecentSimilarReflection(queue, painSourceKey, now);
658
+
659
+ if (recentSimilarReflection) {
660
+ const completedTime = new Date(recentSimilarReflection.completed_at!).getTime();
661
+ logger?.debug?.(`[PD:EvolutionWorker] Skipping sleep_reflection — similar reflection completed ${Math.round((now - completedTime) / 60000)}min ago (same pain pattern: ${painSourceKey})`);
662
+ return true;
663
+ }
664
+ return false;
665
+ }
666
+
667
+ /**
668
+ * Load and migrate the evolution queue. Returns empty array if file doesn't exist.
669
+ */
670
+ function loadEvolutionQueue(queuePath: string): EvolutionQueueItem[] {
671
+ let rawQueue: RawQueueItem[] = [];
672
+ try {
673
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
674
+ } catch {
675
+ // Queue doesn't exist yet - create empty array
676
+ rawQueue = [];
677
+ }
678
+ return migrateQueueToV2(rawQueue);
679
+ }
680
+
681
+ /**
682
+ * Build and persist a new sleep_reflection task.
683
+ */
684
+ function enqueueNewSleepReflectionTask(
685
+ queue: EvolutionQueueItem[],
686
+ recentPainContext: ReturnType<typeof readRecentPainContext>,
687
+ queuePath: string,
688
+ logger: PluginLogger,
689
+ ): void {
690
+ const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', Date.now());
691
+ const nowIso = new Date().toISOString();
692
+
693
+ queue.push({
694
+ id: taskId,
695
+ taskKind: 'sleep_reflection',
696
+ priority: 'medium',
697
+ score: 50,
698
+ source: 'nocturnal',
699
+ reason: 'Sleep-mode reflection triggered by idle workspace',
700
+ trigger_text_preview: 'Idle workspace detected',
701
+ timestamp: nowIso,
702
+ enqueued_at: nowIso,
703
+ status: 'pending',
704
+ traceId: taskId,
705
+ retryCount: 0,
706
+ maxRetries: 1, // sleep_reflection doesn't retry
707
+ recentPainContext,
708
+ });
709
+
710
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
711
+ logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
712
+ }
713
+
593
714
  /**
594
715
  * Enqueue a sleep_reflection task if one is not already pending.
595
716
  * Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
717
+ * Phase 3c: Dedup checks recent sleep_reflection tasks by pain source pattern
718
+ * to prevent redundant reflections of the same underlying issue.
596
719
  */
597
720
  async function enqueueSleepReflectionTask(
598
721
  wctx: WorkspaceContext,
599
- logger: PluginLogger
722
+ logger: PluginLogger,
600
723
  ): Promise<void> {
601
724
  const queuePath = wctx.resolve('EVOLUTION_QUEUE');
602
725
  const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
603
726
 
604
727
  try {
605
- let rawQueue: RawQueueItem[] = [];
606
- try {
607
- rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
608
- } catch {
609
- // Queue doesn't exist yet - create empty array
610
- rawQueue = [];
611
- }
612
-
613
- const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
728
+ const queue = loadEvolutionQueue(queuePath);
614
729
 
615
- // Check if a sleep_reflection task is already pending
616
- const hasPendingSleepReflection = queue.some(
617
- t => t.taskKind === 'sleep_reflection' && (t.status === 'pending' || t.status === 'in_progress')
618
- );
619
- if (hasPendingSleepReflection) {
730
+ // Guard 1: Skip if a sleep_reflection task is already pending/in-progress
731
+ if (hasPendingTask(queue, 'sleep_reflection')) {
620
732
  logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
621
733
  return;
622
734
  }
623
735
 
624
- const now = Date.now();
625
- const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', now);
626
- const nowIso = new Date(now).toISOString();
736
+ // Guard 2: Dedup — skip if similar reflection completed recently
737
+ if (shouldSkipForDedup(queue, wctx, logger)) {
738
+ return;
739
+ }
627
740
 
628
- // Attach recent pain context if available
741
+ // Enqueue the new task
629
742
  const recentPainContext = readRecentPainContext(wctx);
630
-
631
- queue.push({
632
- id: taskId,
633
- taskKind: 'sleep_reflection',
634
- priority: 'medium',
635
- score: 50,
636
- source: 'nocturnal',
637
- reason: 'Sleep-mode reflection triggered by idle workspace',
638
- trigger_text_preview: 'Idle workspace detected',
639
- timestamp: nowIso,
640
- enqueued_at: nowIso,
641
- status: 'pending',
642
- traceId: taskId,
643
- retryCount: 0,
644
- maxRetries: 1, // sleep_reflection doesn't retry
645
- recentPainContext,
646
- });
647
-
648
- fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
649
- logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
743
+ enqueueNewSleepReflectionTask(queue, recentPainContext, queuePath, logger);
650
744
  } finally {
651
745
  releaseLock();
652
746
  }
@@ -1565,7 +1659,7 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1565
1659
  // Phase 2: If no trajectory data, try pain-context fallback
1566
1660
  if (!snapshotData && sleepTask.recentPainContext) {
1567
1661
  logger?.warn?.(`[PD:EvolutionWorker] Using pain-context fallback for ${sleepTask.id}: trajectory snapshot unavailable, will try session summary from extractor`);
1568
- snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor) ?? undefined;
1662
+ snapshotData = buildFallbackNocturnalSnapshot(sleepTask, extractor, logger) ?? undefined;
1569
1663
  }
1570
1664
 
1571
1665
  const snapshotValidation = validateNocturnalSnapshotIngress(snapshotData);
@@ -0,0 +1,191 @@
1
+ import type { OpenClawPluginApi } from '../openclaw-sdk.js';
2
+ import { Type } from '@sinclair/typebox';
3
+ import { buildPainFlag, writePainFlag } from '../core/pain.js';
4
+ import { resolveWorkspaceDirFromApi } from '../core/path-resolver.js';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+
8
+ // Pain flag contract required fields
9
+ const PAIN_FLAG_REQUIRED_FIELDS = ['source', 'score', 'time', 'reason'] as const;
10
+
11
+ /**
12
+ * Atomic file write: write to temp file then rename.
13
+ * Prevents corruption if process crashes mid-write.
14
+ */
15
+ function writePainFlagAtomic(filePath: string, content: string): void {
16
+ const dir = path.dirname(filePath);
17
+ if (!fs.existsSync(dir)) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+ const tmpPath = `${filePath}.tmp.${Date.now()}.${process.pid}`;
21
+ fs.writeFileSync(tmpPath, content, 'utf-8');
22
+ fs.renameSync(tmpPath, filePath);
23
+ }
24
+
25
+ /**
26
+ * Creates the `write_pain_flag` tool.
27
+ *
28
+ * This tool allows the agent to record a pain signal when it recognizes
29
+ * that it made a mistake, violated a principle, or needs to flag an issue
30
+ * for later reflection.
31
+ *
32
+ * The tool wraps `buildPainFlag` + atomic `writePainFlag` to ensure:
33
+ * - Correct KV format serialization (never [object Object] corruption)
34
+ * - Atomic writes (temp file + rename, crash-safe)
35
+ * - Full contract compliance (source, score, time, reason)
36
+ *
37
+ * The agent should NEVER write to .pain_flag directly.
38
+ */
39
+ export function createWritePainFlagTool(api: OpenClawPluginApi) {
40
+ return {
41
+ name: 'write_pain_flag',
42
+ description:
43
+ 'Record a pain signal to flag mistakes, principle violations, or issues for later reflection. ' +
44
+ 'Use this tool INSTEAD of writing .pain_flag directly. ' +
45
+ 'Pain signals are processed by the evolution system on the next heartbeat cycle.',
46
+ parameters: Type.Object({
47
+ reason: Type.String({
48
+ description:
49
+ 'Describe specifically what went wrong. ' +
50
+ 'Include the error, the violated principle, or the issue. ' +
51
+ 'Be concrete: "I edited config.ts without reading it first, breaking the export" ' +
52
+ 'is better than "I made a mistake".',
53
+ }),
54
+ score: Type.Optional(Type.Number({
55
+ description:
56
+ 'Pain severity score (0-100). Default: 80. ' +
57
+ 'Guidelines: 30-50 (minor issue), 50-70 (moderate error), ' +
58
+ '70-100 (severe principle violation or data loss risk).',
59
+ minimum: 0,
60
+ maximum: 100,
61
+ })),
62
+ source: Type.Optional(Type.String({
63
+ description:
64
+ 'Source of the pain signal. ' +
65
+ 'Values: manual (user flagged), tool_failure (tool error), ' +
66
+ 'user_empathy (user frustration), principle_violation (principle broken), ' +
67
+ 'human_intervention (user manually intervened). ' +
68
+ 'Default: manual.',
69
+ })),
70
+ session_id: Type.Optional(Type.String({
71
+ description:
72
+ 'Session ID where the pain occurred. ' +
73
+ 'If not provided, the system will use the current session.',
74
+ })),
75
+ is_risky: Type.Optional(Type.Boolean({
76
+ description:
77
+ 'Whether this involves a high-risk operation (e.g., writing to sensitive files). ' +
78
+ 'Default: false.',
79
+ })),
80
+ }),
81
+
82
+ async execute(
83
+ _toolCallId: string,
84
+ rawParams: Record<string, unknown>
85
+ ): Promise<{ content: { type: string; text: string }[] }> {
86
+ const reason = typeof rawParams.reason === 'string' ? rawParams.reason.trim() : '';
87
+ const score = typeof rawParams.score === 'number' ? Math.max(0, Math.min(100, Math.round(rawParams.score))) : 80;
88
+ const source = typeof rawParams.source === 'string' && rawParams.source.trim() ? rawParams.source.trim() : 'manual';
89
+ const sessionId = typeof rawParams.session_id === 'string' ? rawParams.session_id.trim() : '';
90
+ const isRisky = rawParams.is_risky === true;
91
+
92
+ // ── Validate required fields ──
93
+ if (!reason) {
94
+ api.logger?.warn?.('[PD:write_pain_flag] Missing required field: reason');
95
+ return {
96
+ content: [{
97
+ type: 'text',
98
+ text: '❌ Error: The `reason` parameter is required.\n' +
99
+ 'Describe specifically what went wrong. Example:\n' +
100
+ '"I edited config.ts without reading it first, breaking the export"',
101
+ }],
102
+ };
103
+ }
104
+
105
+ // ── Resolve workspace ──
106
+ const workspaceDir = resolveWorkspaceDirFromApi(api);
107
+ if (!workspaceDir) {
108
+ api.logger?.error?.('[PD:write_pain_flag] Cannot resolve workspace directory');
109
+ return {
110
+ content: [{
111
+ type: 'text',
112
+ text: '❌ Error: Cannot determine the workspace directory. ' +
113
+ 'Please ensure you are in an active workspace.',
114
+ }],
115
+ };
116
+ }
117
+
118
+ try {
119
+ // ── Build pain flag data (KV format) ──
120
+ const painData = buildPainFlag({
121
+ source,
122
+ score: String(score),
123
+ reason,
124
+ session_id: sessionId,
125
+ is_risky: isRisky,
126
+ });
127
+
128
+ // ── Validate contract compliance ──
129
+ const missingFields: string[] = [];
130
+ for (const field of PAIN_FLAG_REQUIRED_FIELDS) {
131
+ if (!painData[field] || painData[field].trim() === '') {
132
+ missingFields.push(field);
133
+ }
134
+ }
135
+ if (missingFields.length > 0) {
136
+ api.logger?.error?.(`[PD:write_pain_flag] Pain flag missing required fields: ${missingFields.join(', ')}`);
137
+ return {
138
+ content: [{
139
+ type: 'text',
140
+ text: `❌ Error: Pain flag is missing required fields: ${missingFields.join(', ')}. ` +
141
+ 'This is an internal error — please report it.',
142
+ }],
143
+ };
144
+ }
145
+
146
+ // ── Atomic write (temp file + rename) ──
147
+ const painFlagPath = path.join(workspaceDir, '.state', '.pain_flag');
148
+ const { serializeKvLines } = await import('../utils/io.js');
149
+ const content = serializeKvLines(painData);
150
+ writePainFlagAtomic(painFlagPath, content);
151
+
152
+ // ── Log success ──
153
+ api.logger?.info?.(
154
+ `[PD:write_pain_flag] Pain signal recorded: source=${source}, score=${score}, ` +
155
+ `reason="${reason.slice(0, 80)}"${reason.length > 80 ? '...' : ''}"`
156
+ );
157
+
158
+ // ── Agent feedback ──
159
+ return {
160
+ content: [{
161
+ type: 'text',
162
+ text: `✅ Pain signal recorded successfully.\n\n` +
163
+ `- **Reason**: ${reason}\n` +
164
+ `- **Score**: ${score}/100\n` +
165
+ `- **Source**: ${source}\n` +
166
+ `- **Risk**: ${isRisky ? 'Yes' : 'No'}\n` +
167
+ `- **Session**: ${sessionId || '(current)'}\n\n` +
168
+ `The evolution system will process this signal on the next heartbeat cycle ` +
169
+ `(typically within 60 seconds).`,
170
+ }],
171
+ };
172
+ } catch (err) {
173
+ // ── Log failure with stack trace ──
174
+ const errorMsg = err instanceof Error ? err.message : String(err);
175
+ const stack = err instanceof Error ? err.stack?.split('\n').slice(0, 3).join(' → ') : '';
176
+ api.logger?.error?.(
177
+ `[PD:write_pain_flag] Failed to write pain flag: ${errorMsg}` +
178
+ (stack ? `\n Stack: ${stack}` : '')
179
+ );
180
+
181
+ return {
182
+ content: [{
183
+ type: 'text',
184
+ text: `❌ Failed to record pain signal: ${errorMsg}\n\n` +
185
+ 'The error has been logged. Please try again or report this issue.',
186
+ }],
187
+ };
188
+ }
189
+ },
190
+ };
191
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pd-pain-signal
3
- description: Manually inject a pain signal into the evolution system by writing to .state/.pain_flag. TRIGGER CONDITIONS: (1) User reports agent stuck/looping/unresponsive (2) User says "record this issue", "force reflection", "trigger pain" (3) Tool failure with no follow-up action (4) User provides human intervention feedback.
3
+ description: Manually inject a pain signal into the evolution system. TRIGGER CONDITIONS: (1) User reports agent stuck/looping/unresponsive (2) User says "record this issue", "force reflection", "trigger pain" (3) Tool failure with no follow-up action (4) User provides human intervention feedback.
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -9,30 +9,44 @@ disable-model-invocation: true
9
9
  You are now the "Manual Intervention Pain" component.
10
10
 
11
11
  **Task**:
12
- 1. Write the user's feedback `$ARGUMENTS` as a **high-priority** pain signal to `.state/.pain_flag`.
12
+ 1. Record the user's feedback `$ARGUMENTS` as a **high-priority** pain signal.
13
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
14
 
15
- **Write Format** (must use this KV format, fields sorted alphabetically):
15
+ **⚠️ Write Rules (MUST follow)**
16
+
17
+ **The ONLY correct way**: Use the `write_pain_flag` tool.
16
18
 
17
19
  ```
18
- agent_id: <current agent ID, e.g., main/builder/diagnostician>
19
- is_risky: false
20
- reason: <user's feedback verbatim>
21
- score: 80
22
- session_id: <current session ID>
23
- source: human_intervention
24
- time: <ISO 8601 timestamp>
20
+ write_pain_flag({
21
+ reason: "User feedback or error description",
22
+ score: 80,
23
+ source: "human_intervention",
24
+ is_risky: false
25
+ })
25
26
  ```
26
27
 
27
- **Required fields** (4):
28
- - `source`: Fixed as `human_intervention`
29
- - `score`: Default `80` for manual intervention (high priority)
30
- - `time`: ISO 8601 timestamp
31
- - `reason`: User's feedback verbatim
28
+ **Absolutely forbidden**:
29
+ - ❌ Writing to `.state/.pain_flag` directly (any method)
30
+ - Using bash heredoc (`cat <<EOF > .pain_flag`)
31
+ - ❌ Using `echo "..." > .pain_flag`
32
+ - ❌ Using `node -e` to call `writePainFlag` or `buildPainFlag`
33
+ - ❌ Any method that `toString()` a JavaScript object to the file
34
+
35
+ **Why use the tool?**
36
+ The `write_pain_flag` tool encapsulates correct KV-format serialization, ensuring `.pain_flag` is never corrupted. Historically, direct file writes caused `[object Object]` corruption multiple times.
32
37
 
33
- **Optional fields** (auto-filled by system, but must be provided for manual injection):
34
- - `agent_id`: Current agent ID (e.g., main/builder/diagnostician)
35
- - `session_id`: Current session ID (from context)
36
- - `is_risky`: Fixed as `false`
38
+ **Parameters**:
39
+ - `reason` (required): The reason for the pain signal — describe what went wrong
40
+ - `score` (optional): Pain score 0-100, default 80 (manual intervention)
41
+ - `source` (optional): Source, default `human_intervention`
42
+ - `is_risky` (optional): Whether this is a high-risk action, default false
37
43
 
38
- **Note**: `trace_id` and `trigger_text_preview` are auto-generated by the system — do NOT include them when manually injecting pain signals.
44
+ **Example**:
45
+ ```
46
+ write_pain_flag({
47
+ reason: "Agent edited a file without reading it first, breaking existing logic",
48
+ score: 85,
49
+ source: "human_intervention",
50
+ is_risky: false
51
+ })
52
+ ```
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pd-pain-signal
3
- description: 手动注入痛苦信号到进化系统,写入 .state/.pain_flag。TRIGGER CONDITIONS: (1) 用户报告 agent 卡住/循环/无响应 (2) 用户说"记录这个问题"、"强制反思"、"触发痛觉" (3) 工具失败后 agent 没有后续动作 (4) 用户提供人工干预反馈。
3
+ description: 手动注入痛苦信号到进化系统。TRIGGER CONDITIONS: (1) 用户报告 agent 卡住/循环/无响应 (2) 用户说"记录这个问题"、"强制反思"、"触发痛觉" (3) 工具失败后 agent 没有后续动作 (4) 用户提供人工干预反馈。
4
4
  disable-model-invocation: true
5
5
  ---
6
6
 
@@ -9,30 +9,44 @@ disable-model-invocation: true
9
9
  你现在是"人工干预痛觉"组件。
10
10
 
11
11
  **任务**:
12
- 1. 将用户的反馈 `$ARGUMENTS` 作为一条**高优先级**的痛苦信号,写入 `.state/.pain_flag`。
12
+ 1. 将用户的反馈 `$ARGUMENTS` 作为一条**高优先级**的痛苦信号记录下来。
13
13
  2. 告知用户信号已注入,并建议其等待下一个 Hook 触发(如 Stop 或 PreCompact)或手动运行 `/reflection-log`。
14
14
 
15
- **写入格式**(必须使用以下 KV 格式,字段按字母排序):
15
+ **⚠️ 写入规则(必须遵守)**
16
+
17
+ **唯一正确的方式**: 使用 `write_pain_flag` 工具。
16
18
 
17
19
  ```
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 时间>
20
+ write_pain_flag({
21
+ reason: "用户反馈原文或错误描述",
22
+ score: 80,
23
+ source: "human_intervention",
24
+ is_risky: false
25
+ })
25
26
  ```
26
27
 
27
- **必填字段**(4 个):
28
- - `source`: 固定为 `human_intervention`
29
- - `score`: 人工干预信号默认设为 `80`(高优先级)
30
- - `time`: ISO 8601 时间戳
31
- - `reason`: 用户反馈的原文
28
+ **绝对禁止**:
29
+ - 直接写 `.state/.pain_flag` 文件(任何方式都不行)
30
+ - 使用 bash heredoc(`cat <<EOF > .pain_flag`)
31
+ - ❌ 使用 `echo "..." > .pain_flag`
32
+ - ❌ 使用 `node -e` 调用 `writePainFlag` 或 `buildPainFlag`
33
+ - ❌ 任何将 JavaScript 对象 `toString()` 写入文件的方式
34
+
35
+ **为什么必须用工具?**
36
+ `write_pain_flag` 工具封装了正确的序列化逻辑(KV 格式),确保 `.pain_flag` 文件不会被写坏。历史上多次因为直接写文件导致 `[object Object]` 损坏。
32
37
 
33
- **可选字段**(自动写入时由系统填充,人工注入时必须填写):
34
- - `agent_id`: 当前智能体 ID(如 main/builder/diagnostician)
35
- - `session_id`: 当前会话 ID(从上下文中获取)
36
- - `is_risky`: 固定为 `false`
38
+ **参数说明**:
39
+ - `reason` (必填): 痛苦的原因,描述具体发生了什么
40
+ - `score` (可选): 痛苦分数 0-100,默认 80(人工干预)
41
+ - `source` (可选): 来源,默认 `human_intervention`
42
+ - `is_risky` (可选): 是否高风险,默认 false
37
43
 
38
- **注意**: `trace_id` 和 `trigger_text_preview` 由系统自动生成,人工注入时**不需要**写这两个字段。
44
+ **示例**:
45
+ ```
46
+ write_pain_flag({
47
+ reason: "Agent 没有读取文件就直接编辑,导致现有逻辑被破坏",
48
+ score: 85,
49
+ source: "human_intervention",
50
+ is_risky: false
51
+ })
52
+ ```