principles-disciple 1.53.0 → 1.55.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,113 @@
1
+ ---
2
+ phase: "01-basic-visualization"
3
+ plan: "01-GAP-CLOSURE"
4
+ verified: 2026-04-17T15:30:00Z
5
+ status: passed
6
+ score: "4/4 must-haves verified"
7
+ overrides_applied: 0
8
+ re_verification: false
9
+ gaps: []
10
+ deferred: []
11
+ human_verification: []
12
+ requirement_mismatch:
13
+ note: "Requirement IDs provided (SDK-CORE-03, SDK-ADP-07, SDK-ADP-08, SDK-TEST-02, SDK-TEST-03, SDK-MGMT-01, SDK-MGMT-02) are SDK Core Implementation Phase 1 requirements per REQUIREMENTS.md. This phase (01-basic-visualization) addresses VIZ-04 (Empty state optimization) - a different domain. Requirement coverage cannot be established for IDs that do not belong to this phase."
14
+ ---
15
+
16
+ # Phase 01-basic-visualization: GAP-CLOSURE Verification Report
17
+
18
+ **Phase Goal:** Close verification gaps from Phase 01 by implementing missing i18n fixes for LineChart component and coverage trend empty state.
19
+ **Verified:** 2026-04-17T15:30:00Z
20
+ **Status:** passed
21
+ **Re-verification:** No — initial verification
22
+
23
+ ## Goal Achievement
24
+
25
+ ### Observable Truths
26
+
27
+ | # | Truth | Status | Evidence |
28
+ | --- | ----- | ------ | -------- |
29
+ | 1 | LineChart interface has emptyText prop for i18n | VERIFIED | `emptyText?: string;` at line 864 in charts.tsx |
30
+ | 2 | LineChart renders emptyText prop instead of hardcoded '暂无数据' | VERIFIED | Conditional render at lines 878-885: `if (!emptyText) return null;` then `{emptyText}` |
31
+ | 3 | Coverage trend section shows EmptyState when no data | VERIFIED | Ternary at line 259: `data.coverageTrend.length >= 1 ? (...) : (<EmptyState ...>)` |
32
+ | 4 | All LineChart usages pass emptyText prop | VERIFIED | Lines 272, 454, 521 all have `emptyText={t('common.noData')}` |
33
+
34
+ **Score:** 4/4 truths verified
35
+
36
+ ### Deferred Items
37
+
38
+ None
39
+
40
+ ### Required Artifacts
41
+
42
+ | Artifact | Expected | Status | Details |
43
+ | -------- | -------- | ------ | ------- |
44
+ | `charts.tsx:864` | `emptyText?: string` in LineChartProps | VERIFIED | Prop exists at correct line |
45
+ | `charts.tsx:876` | Default value `emptyText = ''` | VERIFIED | Default empty string assigned |
46
+ | `charts.tsx:878-885` | Conditional render using emptyText | VERIFIED | Returns null if no emptyText, renders div with {emptyText} otherwise |
47
+ | `ThinkingModelsPage.tsx:259` | Ternary operator for coverage trend | VERIFIED | `data.coverageTrend.length >= 1 ? (...) : (<EmptyState ...>)` |
48
+ | `ThinkingModelsPage.tsx:272` | LineChart with emptyText prop | VERIFIED | `emptyText={t('common.noData')}` |
49
+ | `ThinkingModelsPage.tsx:454` | LineChart with emptyText prop | VERIFIED | `emptyText={t('common.noData')}` |
50
+ | `ThinkingModelsPage.tsx:521` | LineChart with emptyText prop | VERIFIED | `emptyText={t('common.noData')}` |
51
+
52
+ ### Key Link Verification
53
+
54
+ | From | To | Via | Status | Details |
55
+ | ---- | --- | --- | ------ | ------- |
56
+ | LineChart | i18n system | emptyText prop | WIRED | emptyText prop accepts i18n string and renders it |
57
+ | Coverage trend | EmptyState | ternary operator | WIRED | Ternary correctly switches between LineChart and EmptyState |
58
+ | ThinkingModelsPage | common.noData | t() function | WIRED | All LineChart usages pass i18n key |
59
+ | EmptyState | i18n keys | t() function | WIRED | `t('thinkingModels.emptyCoverageTrend')` and `t('thinkingModels.emptyCoverageTrendDesc')` used |
60
+
61
+ ### Data-Flow Trace (Level 4)
62
+
63
+ | Artifact | Data Variable | Source | Produces Real Data | Status |
64
+ | -------- | ------------- | ------ | ------------------ | ------ |
65
+ | LineChart emptyText | emptyText prop | Parent component via t('common.noData') | N/A | STATIC — i18n key is static, not dynamic |
66
+
67
+ ### Behavioral Spot-Checks
68
+
69
+ | Behavior | Command | Result | Status |
70
+ | -------- | ------- | ------ | ------ |
71
+ | emptyText in interface | `grep -n "emptyText?: string" charts.tsx` | `864: emptyText?: string;` | PASS |
72
+ | No hardcoded Chinese | `grep -n "暂无数据" charts.tsx` | No matches | PASS |
73
+ | Ternary for coverage | `grep -n "coverageTrend.length.*?" ThinkingModelsPage.tsx` | `259: {data.coverageTrend.length >= 1 ?` | PASS |
74
+ | EmptyState for coverage | `grep -n "EmptyState" ThinkingModelsPage.tsx \| head -3` | Lines 276-279 with i18n keys | PASS |
75
+ | All LineChart have emptyText | `grep -B5 -A10 "<LineChart" ThinkingModelsPage.tsx \| grep -c "emptyText"` | 3 | PASS |
76
+
77
+ ### Requirements Coverage
78
+
79
+ **IMPORTANT - Requirement Mismatch Found:**
80
+
81
+ | Requirement | Source | Description | Status | Evidence |
82
+ | ----------- | ------ | ----------- | ------ | -------- |
83
+ | SDK-CORE-03 | User-provided | Implement universal PainSignal interface logic | N/A | Does not belong to 01-basic-visualization phase |
84
+ | SDK-ADP-07 | User-provided | Implement Coding domain adapter | N/A | Does not belong to 01-basic-visualization phase |
85
+ | SDK-ADP-08 | User-provided | Implement second domain adapter | N/A | Does not belong to 01-basic-visualization phase |
86
+ | SDK-TEST-02 | User-provided | Implement full Adapter conformance test suite | N/A | Does not belong to 01-basic-visualization phase |
87
+ | SDK-TEST-03 | User-provided | Execute and publish performance benchmarks | N/A | Does not belong to 01-basic-visualization phase |
88
+ | SDK-MGMT-01 | User-provided | Package SDK as @principles/core npm package | N/A | Does not belong to 01-basic-visualization phase |
89
+ | SDK-MGMT-02 | User-provided | Establish Semver versioning and migration guides | N/A | Does not belong to 01-basic-visualization phase |
90
+ | VIZ-04 | GAP-CLOSURE-PLAN | Empty state optimization | VERIFIED | All 4 must_haves from GAP-CLOSURE-PLAN verified |
91
+
92
+ **The 7 requirement IDs provided are SDK Core Implementation Phase 1 requirements per REQUIREMENTS.md. They do not map to the 01-basic-visualization phase. The phase's actual scope is VIZ-04 (Empty state optimization), which has been verified through must_haves.**
93
+
94
+ ### Anti-Patterns Found
95
+
96
+ | File | Line | Pattern | Severity | Impact |
97
+ | ---- | ---- | ------- | -------- | ------ |
98
+ | None | - | No anti-patterns detected | - | - |
99
+
100
+ ### Human Verification Required
101
+
102
+ None required — all truths verifiable via static analysis.
103
+
104
+ ### Gaps Summary
105
+
106
+ **None.** All 4 must_haves verified. Phase goal achieved.
107
+
108
+ **Notable finding:** The requirement IDs provided by user (SDK-CORE-03, SDK-ADP-07, SDK-ADP-08, SDK-TEST-02, SDK-TEST-03, SDK-MGMT-01, SDK-MGMT-02) are SDK Core Implementation Phase 1 requirements per REQUIREMENTS.md. This phase (01-basic-visualization) addresses VIZ-04 (Empty state optimization for visualization) - a different scope. Requirement coverage cannot be established for IDs that do not belong to this phase.
109
+
110
+ ---
111
+
112
+ _Verified: 2026-04-17T15:30:00Z_
113
+ _Verifier: Claude (gsd-verifier)_
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.53.0",
5
+ "version": "1.55.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.53.0",
3
+ "version": "1.55.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -82,7 +82,7 @@ export function bootstrapRules(stateDir: string, limit = 3): BootstrapResult[] {
82
82
  // Migration: if T-01..T-10 exist in Training Store but not in Ledger Tree, backfill.
83
83
  // This handles workspaces initialized before Ledger Tree was added.
84
84
  const store = loadStore(stateDir);
85
- const ledger = loadLedger(stateDir);
85
+ let ledger = loadLedger(stateDir);
86
86
  const hasTrainingT = Object.keys(store).some((id) => id.startsWith('T-'));
87
87
  const hasAnyLedgerT = Object.keys(ledger.tree.principles).some((id) => id.startsWith('T-'));
88
88
  if (hasTrainingT && !hasAnyLedgerT) {
@@ -115,6 +115,8 @@ export function bootstrapRules(stateDir: string, limit = 3): BootstrapResult[] {
115
115
  };
116
116
  addPrincipleToLedger(stateDir, lp);
117
117
  }
118
+ // Reload ledger after migration so subsequent reads see the new data.
119
+ ledger = loadLedger(stateDir);
118
120
  }
119
121
 
120
122
  // Select principles for bootstrap
@@ -15,7 +15,7 @@
15
15
  * SAFETY:
16
16
  * - Never load entire file (tail-only, max 512KB)
17
17
  * - Skip lines > 100KB (real files have 11MB single lines)
18
- * - Cap total output at 1500 chars
18
+ * - Cap total output at 2000 chars
19
19
  * - All errors caught silently — return empty string on failure
20
20
  */
21
21
 
@@ -35,9 +35,9 @@ const TAIL_READ_SIZE = 512_000; // 512KB
35
35
  /** Max turns to extract */
36
36
  const MAX_TURNS = 8;
37
37
  /** Max chars per turn entry */
38
- const MAX_TURN_CHARS = 250;
38
+ const MAX_TURN_CHARS = 400;
39
39
  /** Max total output */
40
- const MAX_OUTPUT_CHARS = 1500;
40
+ const MAX_OUTPUT_CHARS = 2000;
41
41
 
42
42
  /** Valid characters for session IDs and agent IDs — prevents path traversal */
43
43
  const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
@@ -155,19 +155,24 @@ function extractTurn(msg: ParsedMessage): string | null {
155
155
  }
156
156
 
157
157
  if (msg.role === 'assistant') {
158
- // Priority 1: final text reply
158
+ const parts: string[] = [];
159
+
160
+ // Priority 1: final text reply (if present)
159
161
  if (msg.textParts.length > 0) {
160
162
  const text = msg.textParts.join(' ').trim();
161
- if (text) return `[Assistant]: ${text.substring(0, MAX_TURN_CHARS)}`;
163
+ if (text) parts.push(`[Assistant]: ${text.substring(0, MAX_TURN_CHARS)}`);
162
164
  }
163
- // Priority 2: tool call summary (what operations were performed)
165
+
166
+ // Priority 2: tool call summary (always include if present, for context)
164
167
  if (msg.toolCalls.length > 0) {
165
168
  const tools = msg.toolCalls.map(tc => tc.name).filter(Boolean);
166
169
  const uniqueTools = [...new Set(tools)];
167
170
  if (uniqueTools.length > 0) {
168
- return `[Assistant → ${uniqueTools.join(', ')}]`;
171
+ parts.push(`[Assistant → ${uniqueTools.join(', ')}]`);
169
172
  }
170
173
  }
174
+
175
+ return parts.length > 0 ? parts.join(' ') : null;
171
176
  }
172
177
 
173
178
  if (msg.role === 'toolResult') {
@@ -0,0 +1,38 @@
1
+ import * as fs from 'fs';
2
+ import { resolvePdPath } from './paths.js';
3
+
4
+ export const PAIN_FLAG_FILENAME = '.pain_flag';
5
+
6
+ /**
7
+ * Removes the .pain_flag file from the workspace's .state directory.
8
+ * Called when a pain signal task completes (success, timeout, duplicate, or invalid)
9
+ * to prevent stale flags from triggering repeated processing.
10
+ *
11
+ * Optionally verifies the file content before deleting to prevent accidentally removing
12
+ * a concurrent new signal that was written between checkPainFlag reading the file and
13
+ * this deletion call (TOCTOU race).
14
+ *
15
+ * @param workspaceDir - Workspace directory
16
+ * @param expectedPainEventId - If provided, only deletes the file if its pain_event_id matches.
17
+ * This prevents deleting a newly written signal during a race window.
18
+ */
19
+ export function clearPainFlag(workspaceDir: string, expectedPainEventId?: number | string): void {
20
+ const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
21
+ try {
22
+ // Guard against TOCTOU race: if expectedPainEventId is provided,
23
+ // re-read the file and verify the pain_event_id matches before deleting.
24
+ // This prevents accidentally removing a new signal written between
25
+ // checkPainFlag reading the flag and this deletion.
26
+ if (expectedPainEventId !== undefined) {
27
+ const content = fs.readFileSync(painFlagPath, 'utf8');
28
+ const idMatch = content.includes(`pain_event_id: ${expectedPainEventId}`);
29
+ if (!idMatch) {
30
+ // File was rewritten with a different signal — do not delete.
31
+ return;
32
+ }
33
+ }
34
+ fs.unlinkSync(painFlagPath);
35
+ } catch {
36
+ // Best-effort cleanup — ENOENT means already gone, other errors are ignored.
37
+ }
38
+ }
@@ -48,11 +48,11 @@ export const PainSignalSchema = Type.Object({
48
48
  /** Human-readable reason / error description */
49
49
  reason: Type.String({ minLength: 1 }),
50
50
  /** Session ID — identifies which conversation this happened in */
51
- sessionId: Type.String({ minLength: 1 }),
51
+ sessionId: Type.Optional(Type.String()),
52
52
  /** Agent ID — identifies which agent (main, builder, diagnostician, etc.) */
53
- agentId: Type.String({ minLength: 1 }),
53
+ agentId: Type.Optional(Type.String()),
54
54
  /** Correlation trace ID for linking events across the pipeline */
55
- traceId: Type.String({ minLength: 1 }),
55
+ traceId: Type.Optional(Type.String()),
56
56
  /** Preview of the text that triggered this pain */
57
57
  triggerTextPreview: Type.String(),
58
58
  /** Domain context (e.g., 'coding', 'writing', 'analysis') */
@@ -111,6 +111,9 @@ export function validatePainSignal(input: unknown): PainSignalValidationResult {
111
111
  const hydrated = {
112
112
  ...raw,
113
113
  domain: raw.domain ?? 'coding',
114
+ sessionId: raw.sessionId ?? undefined,
115
+ agentId: raw.agentId ?? undefined,
116
+ traceId: raw.traceId ?? undefined,
114
117
  severity: raw.severity ?? deriveSeverity(
115
118
  typeof raw.score === 'number' ? raw.score : 0,
116
119
  ),
@@ -179,8 +179,16 @@ export async function auditEventLogs(
179
179
  recentEntries: recent,
180
180
  });
181
181
 
182
- // Determine primary path (workspace-main or most recent)
183
- if (filePath.includes('workspace-main') || filePath.includes('workspace-main')) {
182
+ // Determine primary path - prefer configured workspace over workspace-main
183
+ // The configured workspace path is {openclawDir}/workspace (without -main suffix)
184
+ const workspaceDir = path.join(openclawDir, 'workspace') + path.sep;
185
+ const workspaceMainDir = path.join(openclawDir, 'workspace-main') + path.sep;
186
+
187
+ if (filePath.startsWith(workspaceDir)) {
188
+ // Configured workspace (e.g., ~/.openclaw/workspace/) takes priority
189
+ primaryPath = filePath;
190
+ } else if (!primaryPath && filePath.startsWith(workspaceMainDir)) {
191
+ // Fallback to workspace-main only if no configured workspace found yet
184
192
  primaryPath = filePath;
185
193
  }
186
194
  } catch {
@@ -52,6 +52,7 @@ import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
52
52
  import { classifyFailure, type ClassifiableTaskKind } from './failure-classifier.js';
53
53
  import { recordPersistentFailure, resetFailureState, isTaskKindInCooldown } from './cooldown-strategy.js';
54
54
  import { reconcileStartup } from './startup-reconciler.js';
55
+ import { clearPainFlag } from '../core/pain-lifecycle.js';
55
56
  import { WORKFLOW_TTL_MS } from '../config/defaults/runtime.js';
56
57
  import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
57
58
 
@@ -338,9 +339,9 @@ async function doEnqueuePainTask(
338
339
  score: v.score,
339
340
  timestamp: new Date().toISOString(),
340
341
  reason: v.reason,
341
- sessionId: v.sessionId || '',
342
- agentId: v.agentId || '',
343
- traceId: v.traceId || '',
342
+ sessionId: v.sessionId ?? undefined,
343
+ agentId: v.agentId ?? undefined,
344
+ traceId: v.traceId ?? undefined,
344
345
  triggerTextPreview: v.preview,
345
346
  };
346
347
  const validation: PainSignalValidationResult = validatePainSignal(signalInput);
@@ -348,6 +349,7 @@ async function doEnqueuePainTask(
348
349
  result.skipped_reason = `invalid_pain_signal (${validation.errors.join('; ')})`;
349
350
  if (logger) logger.warn(`[PD:EvolutionWorker] Pain signal validation failed, skipping enqueue: ${validation.errors.join('; ')}`);
350
351
  SystemLogger.log(wctx.workspaceDir, 'PAIN_SIGNAL_INVALID', `Validation errors: ${validation.errors.join('; ')} | source=${v.source} score=${v.score}`);
352
+ clearPainFlag(wctx.workspaceDir);
351
353
  return result;
352
354
  }
353
355
 
@@ -365,6 +367,7 @@ async function doEnqueuePainTask(
365
367
  result.enqueued = true;
366
368
  result.skipped_reason = 'duplicate';
367
369
  if (logger) logger.info(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${v.source} preview=${v.preview || 'N/A'}`);
370
+ clearPainFlag(wctx.workspaceDir);
368
371
  return result;
369
372
  }
370
373
 
@@ -408,6 +411,7 @@ async function doEnqueuePainTask(
408
411
  enqueuedAt: nowIso,
409
412
  });
410
413
  } finally { releaseLock(); }
414
+ clearPainFlag(wctx.workspaceDir);
411
415
  return result;
412
416
  }
413
417
 
@@ -440,6 +444,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
440
444
  if (isQueued) {
441
445
  result.skipped_reason = 'already_queued';
442
446
  if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
447
+ clearPainFlag(wctx.workspaceDir, painEventId);
443
448
  return result;
444
449
  }
445
450
 
@@ -453,6 +458,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
453
458
  result.exists = true;
454
459
  result.skipped_reason = `invalid_pain_flag (${contract.missingFields.join(', ') || contract.format})`;
455
460
  if (logger) logger.warn(`[PD:EvolutionWorker] Invalid pain flag skipped: ${result.skipped_reason}`);
461
+ clearPainFlag(wctx.workspaceDir);
456
462
  return result;
457
463
  }
458
464
 
@@ -493,6 +499,7 @@ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Prom
493
499
  result.enqueued = true;
494
500
  result.skipped_reason = 'already_queued';
495
501
  if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${jsonScore}, source=${jsonSource})`);
502
+ clearPainFlag(wctx.workspaceDir, jsonPain.pain_event_id ? parseInt(jsonPain.pain_event_id, 10) : undefined);
496
503
  return result;
497
504
  }
498
505
 
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { clearPainFlag, PAIN_FLAG_FILENAME } from '../../src/core/pain-lifecycle.js';
5
+ import { resolvePdPath } from '../../src/core/paths.js';
6
+
7
+ describe('PainLifecycle', () => {
8
+ const workspaceDir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'pain-lifecycle-test-'));
9
+ const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
10
+
11
+ beforeEach(() => {
12
+ const stateDir = path.dirname(painFlagPath);
13
+ if (!fs.existsSync(stateDir)) {
14
+ fs.mkdirSync(stateDir, { recursive: true });
15
+ }
16
+ });
17
+
18
+ afterEach(() => {
19
+ if (fs.existsSync(painFlagPath)) fs.unlinkSync(painFlagPath);
20
+ });
21
+
22
+ it('should delete .pain_flag file when it exists', () => {
23
+ fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: test\ntime: 2026-01-01\n', 'utf8');
24
+ expect(fs.existsSync(painFlagPath)).toBe(true);
25
+ clearPainFlag(workspaceDir);
26
+ expect(fs.existsSync(painFlagPath)).toBe(false);
27
+ });
28
+
29
+ it('should not throw when .pain_flag does not exist', () => {
30
+ expect(fs.existsSync(painFlagPath)).toBe(false);
31
+ expect(() => clearPainFlag(workspaceDir)).not.toThrow();
32
+ });
33
+
34
+ it('should export correct filename constant', () => {
35
+ expect(PAIN_FLAG_FILENAME).toBe('.pain_flag');
36
+ });
37
+ });
@@ -66,14 +66,14 @@ describe('PainSignalSchema', () => {
66
66
  expect(Value.Check(PainSignalSchema, signal)).toBe(false);
67
67
  });
68
68
 
69
- it('rejects empty optional fields (sessionId, agentId, traceId, triggerTextPreview)', () => {
69
+ it('accepts empty optional fields (sessionId, agentId, etc.)', () => {
70
70
  const signal = validSignal({
71
71
  sessionId: '',
72
72
  agentId: '',
73
73
  traceId: '',
74
74
  triggerTextPreview: '',
75
75
  });
76
- expect(Value.Check(PainSignalSchema, signal)).toBe(false);
76
+ expect(Value.Check(PainSignalSchema, signal)).toBe(true);
77
77
  });
78
78
 
79
79
  it('accepts any string for domain', () => {
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { clearPainFlag } from '../../src/core/pain-lifecycle.js';
5
+ import { resolvePdPath } from '../../src/core/paths.js';
6
+
7
+ describe('Pain Lifecycle E2E', () => {
8
+ const workspaceDir = fs.mkdtempSync(path.join(fs.realpathSync('/tmp'), 'pain-lifecycle-e2e-'));
9
+ const painFlagPath = resolvePdPath(workspaceDir, 'PAIN_FLAG');
10
+ const stateDir = path.dirname(painFlagPath);
11
+
12
+ beforeEach(() => {
13
+ if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });
14
+ });
15
+
16
+ afterEach(() => {
17
+ if (fs.existsSync(painFlagPath)) fs.unlinkSync(painFlagPath);
18
+ if (fs.existsSync(stateDir)) {
19
+ try { fs.rmSync(stateDir, { recursive: true }); } catch { /* ignore */ }
20
+ }
21
+ });
22
+
23
+ it('should clear pain_flag after writing a valid flag', () => {
24
+ // Simulate write_pain_flag tool writing a flag
25
+ const kvContent = [
26
+ 'source: tool_failure',
27
+ 'score: 85',
28
+ 'reason: Test pain signal',
29
+ 'time: 2026-04-17T00:00:00.000Z',
30
+ ].join('\n') + '\n';
31
+ fs.writeFileSync(painFlagPath, kvContent, 'utf8');
32
+ expect(fs.existsSync(painFlagPath)).toBe(true);
33
+
34
+ // Simulate task enqueue → clear on exit path
35
+ clearPainFlag(workspaceDir);
36
+ expect(fs.existsSync(painFlagPath)).toBe(false);
37
+ });
38
+
39
+ it('should be idempotent — clearing twice should not throw', () => {
40
+ fs.writeFileSync(painFlagPath, 'source: test\nscore: 50\nreason: idem\ntime: 2026-01-01\n', 'utf8');
41
+ expect(() => clearPainFlag(workspaceDir)).not.toThrow();
42
+ expect(() => clearPainFlag(workspaceDir)).not.toThrow();
43
+ });
44
+
45
+ it('should not throw when .state directory does not exist', () => {
46
+ fs.rmSync(stateDir, { recursive: true, force: true });
47
+ expect(() => clearPainFlag(workspaceDir)).not.toThrow();
48
+ });
49
+
50
+ it('should NOT delete file when expectedPainEventId does not match (concurrent rewrite guard)', () => {
51
+ // Write a flag with pain_event_id: 5
52
+ fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: old\ntime: 2026-01-01\npain_event_id: 5\n', 'utf8');
53
+ expect(fs.existsSync(painFlagPath)).toBe(true);
54
+
55
+ // Simulate: another write_pain_flag runs and writes a NEW signal (pain_event_id: 7)
56
+ // before our clearPainFlag with expected id=5 runs
57
+ fs.writeFileSync(painFlagPath, 'source: test\nscore: 90\nreason: new\ntime: 2026-01-02\npain_event_id: 7\n', 'utf8');
58
+
59
+ // clearPainFlag with expected id=5 should NOT delete the new signal (id=7)
60
+ clearPainFlag(workspaceDir, 5);
61
+ expect(fs.existsSync(painFlagPath)).toBe(true);
62
+
63
+ // The file should still contain the new signal
64
+ const remaining = fs.readFileSync(painFlagPath, 'utf8');
65
+ expect(remaining).toContain('pain_event_id: 7');
66
+ });
67
+
68
+ it('should delete file when expectedPainEventId matches', () => {
69
+ fs.writeFileSync(painFlagPath, 'source: test\nscore: 80\nreason: idem\ntime: 2026-01-01\npain_event_id: 42\n', 'utf8');
70
+ expect(fs.existsSync(painFlagPath)).toBe(true);
71
+ clearPainFlag(workspaceDir, 42);
72
+ expect(fs.existsSync(painFlagPath)).toBe(false);
73
+ });
74
+ });