principles-disciple 1.84.0 → 1.86.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.
@@ -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.84.0",
5
+ "version": "1.86.0",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.84.0",
3
+ "version": "1.86.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -32,6 +32,7 @@ import type {
32
32
  import { createEmptyDailyStats } from '../types/event-types.js';
33
33
  import { atomicWriteFileSync } from '../utils/io.js';
34
34
  import type { PluginLogger } from '../openclaw-sdk.js';
35
+ import { redactTelemetryString } from '@principles/core/runtime-v2';
35
36
 
36
37
  const EVENT_LOG_RETENTION_DAYS = 7;
37
38
 
@@ -209,6 +210,88 @@ export class EventLog {
209
210
  this.record('runtime_v2_prompt_activations_injected', 'injected', data.sessionId, data);
210
211
  }
211
212
 
213
+ /**
214
+ * Redact telemetry-sensitive string values in event data before persistence.
215
+ * Applies redactTelemetryString to known high-risk fields (filePath, command,
216
+ * reason, args, new_string, old_string, text, paramsSummary values) and to all
217
+ * string values in tool_call/rulehost_* data as a safety net.
218
+ *
219
+ * ERR-002: never throws; returns data unchanged on error.
220
+ * ERR-045/046: covers composite command strings, Authorization headers, env vars.
221
+ */
222
+ private redactEventData(
223
+ type: EventType,
224
+ data: Record<string, unknown>
225
+ ): Record<string, unknown> {
226
+ try {
227
+ // Known high-risk event types — telemetry that carries tool commands / paths
228
+ const telemetryTypes: Set<EventType> = new Set([
229
+ 'tool_call',
230
+ 'rulehost_evaluated',
231
+ 'rulehost_blocked',
232
+ 'rulehost_requireApproval',
233
+ 'rulehost_auto_correct_proposed',
234
+ 'rulehost_auto_correct_applied',
235
+ 'rule_enforced',
236
+ 'hook_execution',
237
+ 'gate_block',
238
+ 'gate_bypass',
239
+ ]);
240
+
241
+ if (!telemetryTypes.has(type)) return data;
242
+
243
+ const redacted: Record<string, unknown> = {};
244
+ for (const [key, value] of Object.entries(data)) {
245
+ if (typeof value === 'string') {
246
+ redacted[key] = redactTelemetryString(value);
247
+ } else if (Array.isArray(value)) {
248
+ // Recurse into arrays (e.g. correctedFields with original/applied)
249
+ redacted[key] = value.map((item: unknown) => {
250
+ if (typeof item === 'string') {
251
+ return redactTelemetryString(item);
252
+ }
253
+ if (typeof item === 'object' && item !== null) {
254
+ const nested: Record<string, unknown> = {};
255
+ for (const [nk, nv] of Object.entries(item as Record<string, unknown>)) {
256
+ nested[nk] = typeof nv === 'string' ? redactTelemetryString(nv) : nv;
257
+ }
258
+ return nested;
259
+ }
260
+ return item;
261
+ });
262
+ } else if (typeof value === 'object' && value !== null) {
263
+ // Recurse one level for nested objects (e.g. paramsSummary)
264
+ const nested: Record<string, unknown> = {};
265
+ for (const [nk, nv] of Object.entries(value as Record<string, unknown>)) {
266
+ if (typeof nv === 'string') {
267
+ nested[nk] = redactTelemetryString(nv);
268
+ } else {
269
+ nested[nk] = nv;
270
+ }
271
+ }
272
+ redacted[key] = nested;
273
+ } else {
274
+ redacted[key] = value;
275
+ }
276
+ }
277
+ return redacted;
278
+ } catch (e) {
279
+ // ERR-002: fail safe — never write raw payload on redaction failure.
280
+ // Return a masked payload with context so downstream knows what happened.
281
+ const errStr = e instanceof Error ? e.message.slice(0, 200) : String(e).slice(0, 200);
282
+ const masked: Record<string, unknown> = {
283
+ redactionFailure: true,
284
+ redactionStatus: 'failed',
285
+ 'redaction.status': 'failed',
286
+ redactionReason: errStr || 'unknown error',
287
+ redactionDataDropped: true,
288
+ originalType: type,
289
+ originalSessionId: data.sessionId ?? null,
290
+ };
291
+ return masked;
292
+ }
293
+ }
294
+
212
295
  private record(
213
296
  type: EventType,
214
297
  category: EventCategory,
@@ -218,13 +301,15 @@ export class EventLog {
218
301
  const now = new Date();
219
302
  const date = this.formatDate(now);
220
303
 
304
+ const redactedData = this.redactEventData(type, data as Record<string, unknown>);
305
+
221
306
  const entry: EventLogEntry = {
222
307
  ts: now.toISOString(),
223
308
  date,
224
309
  type,
225
310
  category,
226
311
  sessionId,
227
- data: data as Record<string, unknown>,
312
+ data: redactedData,
228
313
  };
229
314
 
230
315
  this.eventBuffer.push(entry);
package/src/hooks/gate.ts CHANGED
@@ -339,6 +339,9 @@ export function handleBeforeToolCall(
339
339
 
340
340
  function _extractParamsSummary(params: Record<string, unknown>): Record<string, unknown> {
341
341
  const summary: Record<string, unknown> = {};
342
+ // NOTE: Do NOT redact here — this feeds into RuleHost.evaluate() which
343
+ // may match against paramsSummary.command. Redaction happens at
344
+ // EventLog.record() before persistence.
342
345
  if (params.file_path) summary.file_path = params.file_path;
343
346
  if (params.path) summary.path = params.path;
344
347
  if (params.command) summary.command = params.command;
@@ -315,4 +315,170 @@ describe('EventLog', () => {
315
315
  expect(stats.evolution.rulehostAutoCorrectApplied).toBe(0);
316
316
  });
317
317
  });
318
+
319
+ describe('telemetry redaction', () => {
320
+ it('redacts lin_api_ token from rulehost_evaluated filePath', () => {
321
+ const sensitivePath = 'curl -s -H "Authorization: lin_api_TEST_REDACT_ME_1234567890ABCDEF" https://api.linear.app';
322
+ eventLog.recordRuleHostEvaluated({
323
+ toolName: 'bash',
324
+ filePath: sensitivePath,
325
+ matched: true,
326
+ decision: 'allow',
327
+ ruleId: 'r1',
328
+ });
329
+ eventLog.flush();
330
+
331
+ const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
332
+ const content = fs.readFileSync(eventsFile, 'utf-8');
333
+ expect(content).not.toContain('lin_api_TEST_REDACT_ME');
334
+ expect(content).toContain('[REDACTED]');
335
+ });
336
+
337
+ it('redacts Authorization header from tool_call data', () => {
338
+ eventLog.recordToolCall('s1', {
339
+ toolName: 'bash',
340
+ command: 'curl -H "Authorization: Bearer sk-TEST_REDACT_ME_1234567890" https://api.example.com',
341
+ error: undefined,
342
+ gfi: 0,
343
+ });
344
+ eventLog.flush();
345
+
346
+ const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
347
+ const content = fs.readFileSync(eventsFile, 'utf-8');
348
+ expect(content).not.toContain('sk-TEST_REDACT_ME_1234567890');
349
+ expect(content).not.toContain('Bearer sk-TEST_REDACT_ME');
350
+ expect(content).toContain('[REDACTED]');
351
+ });
352
+
353
+ it('redacts ghp_ token from tool_call data', () => {
354
+ eventLog.recordToolCall('s1', {
355
+ toolName: 'bash',
356
+ command: 'ghp_TEST_REDACT_ME_1234567890ABCDEFGHIJKLMN',
357
+ error: undefined,
358
+ gfi: 0,
359
+ });
360
+ eventLog.flush();
361
+
362
+ const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
363
+ const content = fs.readFileSync(eventsFile, 'utf-8');
364
+ expect(content).not.toContain('ghp_TEST_REDACT_ME');
365
+ expect(content).toContain('[REDACTED]');
366
+ });
367
+
368
+ it('redacts Bearer token in tool_call data', () => {
369
+ eventLog.recordToolCall('s1', {
370
+ toolName: 'bash',
371
+ command: 'curl -H "Authorization: Bearer TEST_REDACT_ME_TOKEN_1234567890"',
372
+ error: undefined,
373
+ gfi: 0,
374
+ });
375
+ eventLog.flush();
376
+
377
+ const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
378
+ const content = fs.readFileSync(eventsFile, 'utf-8');
379
+ expect(content).not.toContain('TEST_REDACT_ME_TOKEN');
380
+ expect(content).toContain('[REDACTED]');
381
+ });
382
+
383
+ it('redacts env assignment in tool_call data', () => {
384
+ eventLog.recordToolCall('s1', {
385
+ toolName: 'bash',
386
+ command: 'LINEAR_API_KEY=lin_api_TEST_REDACT_ME_1234567890ABCDEF curl -s https://api.linear.app',
387
+ error: undefined,
388
+ gfi: 0,
389
+ });
390
+ eventLog.flush();
391
+
392
+ const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
393
+ const content = fs.readFileSync(eventsFile, 'utf-8');
394
+ expect(content).not.toContain('lin_api_TEST_REDACT_ME');
395
+ expect(content).toContain('[REDACTED]');
396
+ });
397
+
398
+ it('preserves normal file path in rulehost_evaluated', () => {
399
+ const normalPath = 'src/app.ts';
400
+ eventLog.recordRuleHostEvaluated({
401
+ toolName: 'write',
402
+ filePath: normalPath,
403
+ matched: true,
404
+ decision: 'allow',
405
+ ruleId: 'r1',
406
+ });
407
+ eventLog.flush();
408
+
409
+ const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
410
+ const content = fs.readFileSync(eventsFile, 'utf-8');
411
+ expect(content).toContain(normalPath);
412
+ });
413
+
414
+ it('non-telemetry types are not affected', () => {
415
+ eventLog.recordPainSignal('s1', {
416
+ source: 'tool_failure',
417
+ score: 75,
418
+ reason: 'normal pain signal',
419
+ });
420
+ eventLog.flush();
421
+
422
+ const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
423
+ const content = fs.readFileSync(eventsFile, 'utf-8');
424
+ expect(content).toContain('normal pain signal');
425
+ });
426
+
427
+ it('redaction failure returns masked payload, not raw secrets', () => {
428
+ // Contract check: the catch block in redactEventData must NOT return raw data.
429
+ // This is a static regression test for the ERR-002 fail-safe fix.
430
+ const eventLogSource = fs.readFileSync(
431
+ path.resolve(__dirname, '../../src/core/event-log.ts'),
432
+ 'utf-8'
433
+ );
434
+ // Find the catch block lines (after '} catch')
435
+ const afterCatch = eventLogSource.match(/\}[\s\n]*catch[\s\n]*\([^)]*\)[\s\n]*\{([\s\S]*?)\}[\s\n]*(?:private|public|\n)/);
436
+ // If found, verify it doesn't contain 'return data'
437
+ if (afterCatch) {
438
+ expect(afterCatch[1]).not.toMatch(/return\s+data/);
439
+ }
440
+ // The catch block must produce a masked result with redactionStatus
441
+ expect(eventLogSource).toContain('redactionStatus');
442
+ expect(eventLogSource).toContain('redactionDataDropped');
443
+ expect(eventLogSource).toContain('redactionReason');
444
+ });
445
+
446
+ it('verifies that EventLog persistence path does not store raw secret and stores masked fallback on redaction failure', () => {
447
+ // 1. Success case: log a sensitive command
448
+ eventLog.recordToolCall('s1', {
449
+ toolName: 'bash',
450
+ command: 'curl -H "Authorization: Bearer sk-TEST_REDACT_ME_999" https://api.openai.com',
451
+ error: undefined,
452
+ gfi: 0,
453
+ });
454
+ eventLog.flush();
455
+
456
+ const todayStr = new Date().toISOString().slice(0, 10);
457
+ const eventsFile = path.join(tempDir, 'logs', `events_${todayStr}.jsonl`);
458
+ let content = fs.readFileSync(eventsFile, 'utf-8');
459
+ expect(content).not.toContain('sk-TEST_REDACT_ME_999');
460
+ expect(content).toContain('[REDACTED]');
461
+
462
+ // 2. Redaction failure case: use a throwing getter to trigger catch block
463
+ const badData = {
464
+ toolName: 'bash',
465
+ get command(): string {
466
+ throw new Error('Simulated getter crash');
467
+ },
468
+ error: undefined,
469
+ gfi: 0,
470
+ };
471
+
472
+ eventLog.recordToolCall('s1', badData as any);
473
+ eventLog.flush();
474
+
475
+ content = fs.readFileSync(eventsFile, 'utf-8');
476
+ // The raw data must not be written, and instead the failure marker is present
477
+ expect(content).toContain('"redactionFailure":true');
478
+ expect(content).toContain('"redactionStatus":"failed"');
479
+ expect(content).toContain('"redaction.status":"failed"');
480
+ expect(content).toContain('Simulated getter crash');
481
+ });
482
+ });
318
483
  });
484
+
@@ -65,7 +65,7 @@ vi.mock('@principles/core/runtime-v2', () => {
65
65
  };
66
66
  });
67
67
 
68
- import { CorrectionObserverService, runCorrectionObserverCycle } from '../../src/service/correction-observer-service.js';
68
+ import { CorrectionObserverService, runCorrectionObserverCycle, resolveCorrectionObserver } from '../../src/service/correction-observer-service.js';
69
69
  import { safeRmDir } from '../test-utils.js';
70
70
 
71
71
  describe('CorrectionObserverService — Independent Service (PRI-293)', () => {
@@ -329,3 +329,51 @@ describe('runCorrectionObserverCycle — Independent Execution', () => {
329
329
  }
330
330
  });
331
331
  });
332
+
333
+ describe('resolveCorrectionObserver — Configuration Resolution', () => {
334
+ beforeEach(() => {
335
+ vi.clearAllMocks();
336
+ });
337
+
338
+ it('returns observer when API key env is set with mocked policy', async () => {
339
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-resolve-'));
340
+ const stateDir = path.join(workspaceDir, '.state');
341
+ fs.mkdirSync(stateDir, { recursive: true });
342
+
343
+ process.env.ANTHROPIC_API_KEY = 'test-key';
344
+
345
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
346
+
347
+ try {
348
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
349
+ const result = resolveCorrectionObserver(wctx, logger as any);
350
+
351
+ // With mocked WorkflowFunnelLoader returning valid policy, should return observer
352
+ expect(result).not.toBeNull();
353
+ } finally {
354
+ delete process.env.ANTHROPIC_API_KEY;
355
+ safeRmDir(workspaceDir);
356
+ }
357
+ });
358
+
359
+ it('returns observer when workflows.yaml provides valid policy', async () => {
360
+ const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-policy-'));
361
+ const stateDir = path.join(workspaceDir, '.state');
362
+ fs.mkdirSync(stateDir, { recursive: true });
363
+
364
+ process.env.ANTHROPIC_API_KEY = 'test-key';
365
+
366
+ const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
367
+
368
+ try {
369
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
370
+ const result = resolveCorrectionObserver(wctx, logger as any);
371
+
372
+ // With mocked WorkflowFunnelLoader returning valid policy, should return observer
373
+ expect(result).not.toBeNull();
374
+ } finally {
375
+ delete process.env.ANTHROPIC_API_KEY;
376
+ safeRmDir(workspaceDir);
377
+ }
378
+ });
379
+ });
@@ -7,6 +7,10 @@ import {
7
7
  resolveCanonicalWorkspaceDir,
8
8
  resolveHookWorkspaceDir,
9
9
  resolveToolHookWorkspaceDirSafe,
10
+ resolveCommandWorkspaceDir,
11
+ resolvePluginCommandWorkspaceDir,
12
+ resolveWorkspaceDirForRuntimeV2,
13
+ WorkspaceResolutionError,
10
14
  } from '../../src/utils/workspace-resolver.js';
11
15
  import type { CanonicalWorkspaceResult, HookWorkspaceResolutionResult } from '../../src/utils/workspace-resolver.js';
12
16
 
@@ -345,3 +349,176 @@ describe('resolveToolHookWorkspaceDirSafe (backward compat)', () => {
345
349
  expect(fullWarn).toContain('principles-disciple.json');
346
350
  });
347
351
  });
352
+
353
+ describe('resolveCommandWorkspaceDir — Command Resolution', () => {
354
+ const originalEnv = { ...process.env };
355
+ const logger = {
356
+ error: vi.fn(),
357
+ warn: vi.fn(),
358
+ info: vi.fn(),
359
+ debug: vi.fn(),
360
+ };
361
+
362
+ const api = {
363
+ runtime: {
364
+ agent: {
365
+ resolveAgentWorkspaceDir: vi.fn(),
366
+ },
367
+ },
368
+ config: {},
369
+ logger,
370
+ };
371
+
372
+ beforeEach(() => {
373
+ process.env = { ...originalEnv };
374
+ delete process.env.PD_WORKSPACE_DIR;
375
+ delete process.env.OPENCLAW_WORKSPACE;
376
+ vi.clearAllMocks();
377
+ ensureDir(validWorkspace);
378
+ });
379
+
380
+ afterEach(() => {
381
+ process.env = { ...originalEnv };
382
+ });
383
+
384
+ it('returns ctx.workspaceDir when valid', () => {
385
+ const result = resolveCommandWorkspaceDir(api as any, { workspaceDir: validWorkspace });
386
+ expect(result).toBe(validWorkspace);
387
+ });
388
+
389
+ it('throws when ctx.workspaceDir is home directory', () => {
390
+ expect(() => resolveCommandWorkspaceDir(api as any, { workspaceDir: homeDir }))
391
+ .toThrow(/is invalid/);
392
+ expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('is invalid'));
393
+ });
394
+
395
+ it('throws when ctx.workspaceDir is empty string', () => {
396
+ expect(() => resolveCommandWorkspaceDir(api as any, { workspaceDir: '' }))
397
+ .toThrow(/Cannot resolve workspace directory/);
398
+ });
399
+
400
+ it('falls back to API resolution when ctx.workspaceDir is undefined', () => {
401
+ api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(validWorkspace);
402
+ process.env.OPENCLAW_WORKSPACE = validWorkspace;
403
+ const result = resolveCommandWorkspaceDir(api as any, {});
404
+ expect(result).toBe(path.resolve(validWorkspace));
405
+ });
406
+
407
+ it('falls back to PathResolver default when API throws', () => {
408
+ api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
409
+ throw new Error('API unavailable');
410
+ });
411
+ // PathResolver provides default ~/.openclaw/workspace fallback
412
+ const result = resolveCommandWorkspaceDir(api as any, {});
413
+ expect(result).toBeDefined();
414
+ expect(result).toContain('.openclaw');
415
+ });
416
+ });
417
+
418
+ describe('resolvePluginCommandWorkspaceDir — Plugin Command Resolution', () => {
419
+ beforeEach(() => {
420
+ vi.clearAllMocks();
421
+ ensureDir(validWorkspace);
422
+ });
423
+
424
+ it('returns ctx.workspaceDir when valid', () => {
425
+ const ctx = { workspaceDir: validWorkspace, config: {} };
426
+ const result = resolvePluginCommandWorkspaceDir(ctx as any, 'test-source');
427
+ expect(result).toBe(validWorkspace);
428
+ });
429
+
430
+ it('throws when ctx.workspaceDir is home directory', () => {
431
+ const ctx = { workspaceDir: homeDir, config: {} };
432
+ expect(() => resolvePluginCommandWorkspaceDir(ctx as any, 'test-source'))
433
+ .toThrow(/is invalid/);
434
+ });
435
+
436
+ it('falls back to ctx.config.workspaceDir when ctx.workspaceDir is undefined', () => {
437
+ const ctx = { workspaceDir: undefined, config: { workspaceDir: validWorkspace } };
438
+ const result = resolvePluginCommandWorkspaceDir(ctx as any, 'test-source');
439
+ expect(result).toBe(validWorkspace);
440
+ });
441
+
442
+ it('throws when both ctx.workspaceDir and ctx.config.workspaceDir are invalid', () => {
443
+ const ctx = { workspaceDir: homeDir, config: { workspaceDir: homeDir } };
444
+ expect(() => resolvePluginCommandWorkspaceDir(ctx as any, 'test-source'))
445
+ .toThrow(/is invalid/);
446
+ });
447
+
448
+ it('throws critical error when no workspaceDir available', () => {
449
+ const ctx = { workspaceDir: undefined, config: {} };
450
+ expect(() => resolvePluginCommandWorkspaceDir(ctx as any, 'test-source'))
451
+ .toThrow(/CRITICAL: workspaceDir is not set/);
452
+ });
453
+ });
454
+
455
+ describe('resolveWorkspaceDirForRuntimeV2 — Runtime V2 Resolution', () => {
456
+ beforeEach(() => {
457
+ vi.clearAllMocks();
458
+ ensureDir(validWorkspace);
459
+ });
460
+
461
+ it('returns normalized workspaceDir when valid', () => {
462
+ const result = resolveWorkspaceDirForRuntimeV2(
463
+ { workspaceDir: validWorkspace },
464
+ undefined,
465
+ 'runtime-v2-test',
466
+ );
467
+ expect(result).toBe(path.resolve(validWorkspace));
468
+ });
469
+
470
+ it('throws WorkspaceResolutionError when workspaceDir is empty', () => {
471
+ expect(() => resolveWorkspaceDirForRuntimeV2({ workspaceDir: '' }, undefined, 'test'))
472
+ .toThrow(WorkspaceResolutionError);
473
+
474
+ try {
475
+ resolveWorkspaceDirForRuntimeV2({ workspaceDir: '' }, undefined, 'test');
476
+ } catch (e) {
477
+ expect((e as WorkspaceResolutionError).reason).toBe('workspace_dir_missing');
478
+ expect((e as WorkspaceResolutionError).nextAction).toContain('PD_WORKSPACE_DIR');
479
+ }
480
+ });
481
+
482
+ it('throws WorkspaceResolutionError when workspaceDir is undefined', () => {
483
+ expect(() => resolveWorkspaceDirForRuntimeV2({}, undefined, 'test'))
484
+ .toThrow(WorkspaceResolutionError);
485
+ });
486
+
487
+ it('throws WorkspaceResolutionError when workspaceDir is home directory', () => {
488
+ expect(() => resolveWorkspaceDirForRuntimeV2({ workspaceDir: homeDir }, undefined, 'test'))
489
+ .toThrow(WorkspaceResolutionError);
490
+
491
+ try {
492
+ resolveWorkspaceDirForRuntimeV2({ workspaceDir: homeDir }, undefined, 'test');
493
+ } catch (e) {
494
+ expect((e as WorkspaceResolutionError).reason).toBe('workspace_dir_invalid');
495
+ }
496
+ });
497
+ });
498
+
499
+ describe('WorkspaceResolutionError — Error Structure', () => {
500
+ it('has correct name and properties', () => {
501
+ const error = new WorkspaceResolutionError(
502
+ 'Test message',
503
+ 'test_reason',
504
+ 'Test next action',
505
+ );
506
+ expect(error.name).toBe('WorkspaceResolutionError');
507
+ expect(error.message).toBe('Test message');
508
+ expect(error.reason).toBe('test_reason');
509
+ expect(error.nextAction).toBe('Test next action');
510
+ });
511
+
512
+ it('toJSON returns structured failure object', () => {
513
+ const error = new WorkspaceResolutionError(
514
+ 'Test message',
515
+ 'test_reason',
516
+ 'Test next action',
517
+ );
518
+ const json = error.toJSON();
519
+ expect(json.ok).toBe(false);
520
+ expect(json.reason).toBe('test_reason');
521
+ expect(json.message).toBe('Test message');
522
+ expect(json.nextAction).toBe('Test next action');
523
+ });
524
+ });