@principles/pd-cli 1.83.0 → 1.85.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 (31) hide show
  1. package/dist/commands/pain-evidence.d.ts +41 -0
  2. package/dist/commands/pain-evidence.d.ts.map +1 -0
  3. package/dist/commands/pain-evidence.js +177 -0
  4. package/dist/commands/pain-evidence.js.map +1 -0
  5. package/dist/commands/runtime-uat.d.ts +1 -0
  6. package/dist/commands/runtime-uat.d.ts.map +1 -1
  7. package/dist/commands/runtime-uat.guard.test.d.ts +2 -0
  8. package/dist/commands/runtime-uat.guard.test.d.ts.map +1 -0
  9. package/dist/commands/runtime-uat.guard.test.js +155 -0
  10. package/dist/commands/runtime-uat.guard.test.js.map +1 -0
  11. package/dist/commands/runtime-uat.js +29 -3
  12. package/dist/commands/runtime-uat.js.map +1 -1
  13. package/dist/index.js +12 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/utils/production-workspace-guard.d.ts +77 -0
  16. package/dist/utils/production-workspace-guard.d.ts.map +1 -0
  17. package/dist/utils/production-workspace-guard.js +170 -0
  18. package/dist/utils/production-workspace-guard.js.map +1 -0
  19. package/dist/utils/production-workspace-guard.test.d.ts +2 -0
  20. package/dist/utils/production-workspace-guard.test.d.ts.map +1 -0
  21. package/dist/utils/production-workspace-guard.test.js +95 -0
  22. package/dist/utils/production-workspace-guard.test.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/commands/pain-evidence.ts +210 -0
  25. package/src/commands/runtime-uat.guard.test.ts +177 -0
  26. package/src/commands/runtime-uat.ts +42 -4
  27. package/src/index.ts +13 -0
  28. package/src/utils/production-workspace-guard.test.ts +108 -0
  29. package/src/utils/production-workspace-guard.ts +219 -0
  30. package/tests/commands/pain-evidence.test.ts +479 -0
  31. package/tests/commands/pain-retry.test.ts +64 -0
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Tests for pd pain evidence command.
3
+ *
4
+ * Covers:
5
+ * - Command parser/registration: --workspace, --limit, --json
6
+ * - Real SYSTEM log fixture parsing: TRIGGER_DECISION entries
7
+ * - Malformed log entries: no crash, visible reason/diagnostic
8
+ * - --json stdout: exactly one JSON object
9
+ * - --limit invalid values: fail loud with reason + nextAction
10
+ * - Searched path matches actual log directory
11
+ */
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+ import { Command } from 'commander';
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import os from 'os';
17
+
18
+ // ── Fixture: Real SYSTEM log content with TRIGGER_DECISION entries ──────────
19
+
20
+ const LOG_HEADER_LINES = [
21
+ '2026-06-08 10:15:30 [INFO] [PD:SystemLogger] Session started session_abc',
22
+ '2026-06-08 10:15:31 [INFO] [PD:SystemLogger] painEvidenceAdmission feature flag enabled',
23
+ ];
24
+
25
+ const TRIGGER_DECISION_ENTRIES = [
26
+ '2026-06-08 10:16:00 [INFO] [PD:SystemLogger] TRIGGER_DECISION {"outcome":"evidence_only","sourceKind":"tool_failure","reason":"Tool failure is infrastructure noise.","nextAction":"store_as_evidence","triageDecision":"evidence_only","tool":"read","sessionId":"session_abc"}',
27
+ '2026-06-08 10:17:00 [INFO] [PD:SystemLogger] TRIGGER_DECISION {"outcome":"health_only","sourceKind":"provider_failure","reason":"Infrastructure health signal.","nextAction":"monitor","triageDecision":"health_only","sessionId":"session_abc"}',
28
+ '2026-06-08 10:18:00 [INFO] [PD:SystemLogger] TRIGGER_DECISION {"outcome":"manual_owner_admitted","sourceKind":"owner_reported","reason":"Owner explicit manual pain. Bypasses triage and cooldown.","nextAction":"create_diagnostic_task","painId":"pain_123","score":100}',
29
+ ];
30
+
31
+ const MALFORMED_ENTRY = '2026-06-08 10:19:00 [INFO] [PD:SystemLogger] TRIGGER_DECISION {not valid json';
32
+ const NON_TRIGGER_ENTRY = '2026-06-08 10:20:00 [INFO] [PD:SystemLogger] PAIN_GATE_REJECTED {"reason":"cooldown"}';
33
+
34
+ const FULL_LOG_CONTENT = [
35
+ ...LOG_HEADER_LINES,
36
+ ...TRIGGER_DECISION_ENTRIES.slice(0, 2),
37
+ MALFORMED_ENTRY,
38
+ ...TRIGGER_DECISION_ENTRIES.slice(2),
39
+ NON_TRIGGER_ENTRY,
40
+ ].join('\n');
41
+
42
+ // ── Imports (under test) ───────────────────────────────────────────────────
43
+
44
+ import { parseTriggerDecisions, getLogDir } from '../../src/commands/pain-evidence.js';
45
+
46
+ declare module '../../src/commands/pain-evidence.js' {
47
+ export function parseTriggerDecisions(logContent: string): import('../../src/commands/pain-evidence.js').TriggerDecisionEntry[];
48
+ export function getLogDir(workspaceDir: string): string;
49
+ }
50
+
51
+ // ── Parser Tests ───────────────────────────────────────────────────────────
52
+
53
+ describe('parseTriggerDecisions', () => {
54
+ it('PARSER-01: parses TRIGGER_DECISION entries from log content', () => {
55
+ const entries = parseTriggerDecisions(FULL_LOG_CONTENT);
56
+
57
+ // Should parse 3 valid entries, skip malformed and non-trigger lines
58
+ expect(entries.length).toBe(3);
59
+
60
+ // First: evidence_only from tool_failure
61
+ expect(entries[0].outcome).toBe('evidence_only');
62
+ expect(entries[0].sourceKind).toBe('tool_failure');
63
+ expect(entries[0].reason).toContain('infrastructure');
64
+ expect(entries[0].timestamp).toBe('2026-06-08 10:16:00');
65
+
66
+ // Second: health_only from provider_failure
67
+ expect(entries[1].outcome).toBe('health_only');
68
+ expect(entries[1].sourceKind).toBe('provider_failure');
69
+
70
+ // Third: manual_owner_admitted
71
+ expect(entries[2].outcome).toBe('manual_owner_admitted');
72
+ expect(entries[2].sourceKind).toBe('owner_reported');
73
+ expect(entries[2].painId).toBe('pain_123');
74
+ expect(entries[2].score).toBe(100);
75
+ });
76
+
77
+ it('PARSER-02: returns empty array for content without TRIGGER_DECISION', () => {
78
+ const entries = parseTriggerDecisions('2026-06-08 10:15:30 [INFO] Normal log line\nMore normal log');
79
+ expect(entries.length).toBe(0);
80
+ });
81
+
82
+ it('PARSER-03: returns empty array for empty content', () => {
83
+ const entries = parseTriggerDecisions('');
84
+ expect(entries.length).toBe(0);
85
+ });
86
+
87
+ it('PARSER-04: malformed JSON entries do not crash and are silently skipped', () => {
88
+ const content = [
89
+ '2026-06-08 10:16:00 [INFO] TRIGGER_DECISION {"outcome":"evidence_only","sourceKind":"tool_failure","reason":"valid","nextAction":"store"}',
90
+ '2026-06-08 10:17:00 [INFO] TRIGGER_DECISION {invalid json}',
91
+ '2026-06-08 10:18:00 [INFO] TRIGGER_DECISION {"outcome":"diagnosis_created","sourceKind":"owner_reported","reason":"valid2","nextAction":"diagnose"}',
92
+ ].join('\n');
93
+
94
+ const entries = parseTriggerDecisions(content);
95
+ expect(entries.length).toBe(2); // malformed skipped
96
+ expect(entries[0].outcome).toBe('evidence_only');
97
+ expect(entries[1].outcome).toBe('diagnosis_created');
98
+ });
99
+
100
+ it('PARSER-05: runtime type validation catches non-string fields', () => {
101
+ const content = '2026-06-08 10:16:00 [INFO] TRIGGER_DECISION {"outcome":123,"sourceKind":["array"],"reason":null,"nextAction":true}';
102
+ const entries = parseTriggerDecisions(content);
103
+ expect(entries.length).toBe(1);
104
+ // Should fall back to defaults when types don't match
105
+ expect(entries[0].outcome).toBe('unknown');
106
+ expect(entries[0].sourceKind).toBe('unknown');
107
+ expect(entries[0].reason).toBe('');
108
+ expect(entries[0].nextAction).toBe('');
109
+ });
110
+
111
+ it('PARSER-06: entries preserve parse order (oldest-first within file)', () => {
112
+ const content = [
113
+ '2026-06-08 10:15:00 [INFO] TRIGGER_DECISION {"outcome":"evidence_only","sourceKind":"a","reason":"r1","nextAction":"n"}',
114
+ '2026-06-08 10:16:00 [INFO] TRIGGER_DECISION {"outcome":"health_only","sourceKind":"b","reason":"r2","nextAction":"n"}',
115
+ '2026-06-08 10:17:00 [INFO] TRIGGER_DECISION {"outcome":"diagnosis_created","sourceKind":"c","reason":"r3","nextAction":"n"}',
116
+ ].join('\n');
117
+
118
+ const entries = parseTriggerDecisions(content);
119
+ expect(entries.length).toBe(3);
120
+ // File is parsed top-to-bottom, entries preserve reading order
121
+ // (readRecentDecisions reverses per-file entries for newest-first)
122
+ expect(entries[0].timestamp).toBe('2026-06-08 10:15:00');
123
+ expect(entries[1].timestamp).toBe('2026-06-08 10:16:00');
124
+ expect(entries[2].timestamp).toBe('2026-06-08 10:17:00');
125
+ });
126
+ });
127
+
128
+ // ── Integration: Real Log File Fixture ─────────────────────────────────────
129
+
130
+ describe('real SYSTEM log fixture', () => {
131
+ const tmpDir = path.join(os.tmpdir(), `pd-test-evidence-${Date.now()}`);
132
+ const logDir = path.join(tmpDir, 'memory', 'logs');
133
+
134
+ beforeEach(() => {
135
+ // Create log directory with fixture file
136
+ fs.mkdirSync(logDir, { recursive: true });
137
+ fs.writeFileSync(path.join(logDir, 'SYSTEM_2026-06-08.log'), FULL_LOG_CONTENT, 'utf8');
138
+ });
139
+
140
+ afterEach(() => {
141
+ fs.rmSync(tmpDir, { recursive: true, force: true });
142
+ });
143
+
144
+ it('FIXTURE-01: reads and parses TRIGGER_DECISION from real SYSTEM log', async () => {
145
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
146
+
147
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
148
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
149
+
150
+ await handlePainEvidence({
151
+ workspace: tmpDir,
152
+ limit: 10,
153
+ json: true,
154
+ });
155
+
156
+ // Find the JSON output call
157
+ const jsonCall = logSpy.mock.calls.find((call) => {
158
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
159
+ });
160
+ expect(jsonCall).toBeDefined();
161
+ const output = JSON.parse(jsonCall![0] as string);
162
+ expect(output.count).toBe(3);
163
+ expect(output.decisions.length).toBe(3);
164
+ expect(output.searchedPath).toContain(path.join('memory', 'logs', 'SYSTEM_*.log'));
165
+ // Entries are newest-first after per-file reversal
166
+ expect(output.decisions[0].outcome).toBe('manual_owner_admitted');
167
+ expect(output.decisions[1].outcome).toBe('health_only');
168
+ expect(output.decisions[2].outcome).toBe('evidence_only');
169
+
170
+ logSpy.mockRestore();
171
+ exitSpy.mockRestore();
172
+ });
173
+
174
+ it('FIXTURE-02: --json stdout is exactly one parseable JSON object', async () => {
175
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
176
+
177
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
178
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
179
+
180
+ await handlePainEvidence({
181
+ workspace: tmpDir,
182
+ limit: 5,
183
+ json: true,
184
+ });
185
+
186
+ // Should produce a valid JSON output
187
+ const jsonCalls = logSpy.mock.calls.filter((call) => {
188
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
189
+ });
190
+ expect(jsonCalls.length).toBeGreaterThanOrEqual(1);
191
+ const parsed = JSON.parse(jsonCalls[0][0] as string);
192
+ expect(parsed.count).toBe(3);
193
+ expect(Array.isArray(parsed.decisions)).toBe(true);
194
+
195
+ logSpy.mockRestore();
196
+ exitSpy.mockRestore();
197
+ });
198
+
199
+ it('FIXTURE-03: non-JSON output shows formatted entries', async () => {
200
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
201
+
202
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
203
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
204
+
205
+ await handlePainEvidence({
206
+ workspace: tmpDir,
207
+ limit: 10,
208
+ json: false,
209
+ });
210
+
211
+ const allOutput = logSpy.mock.calls.map(c => c[0]).join('\n');
212
+ expect(allOutput).toContain('evidence_only');
213
+ expect(allOutput).toContain('health_only');
214
+ expect(allOutput).toContain('manual_owner_admitted');
215
+ expect(allOutput).toContain('Tool failure is infrastructure noise');
216
+ expect(allOutput).toContain('Summary:');
217
+
218
+ logSpy.mockRestore();
219
+ exitSpy.mockRestore();
220
+ });
221
+
222
+ it('FIXTURE-04: empty log returns zero results', async () => {
223
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
224
+
225
+ // Create a log file with no TRIGGER_DECISION entries
226
+ const noTriggerLog = '2026-06-08 [INFO] Normal log content without any trigger decisions.\n';
227
+ fs.writeFileSync(path.join(logDir, 'SYSTEM_2026-06-08.log'), noTriggerLog, 'utf8');
228
+
229
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
230
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
231
+
232
+ await handlePainEvidence({
233
+ workspace: tmpDir,
234
+ limit: 5,
235
+ json: true,
236
+ });
237
+
238
+ const jsonCall = logSpy.mock.calls.find((call) => {
239
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
240
+ });
241
+ expect(jsonCall).toBeDefined();
242
+ const output = JSON.parse(jsonCall![0] as string);
243
+ expect(output.count).toBe(0);
244
+ expect(output.decisions).toEqual([]);
245
+
246
+ logSpy.mockRestore();
247
+ exitSpy.mockRestore();
248
+ });
249
+
250
+ it('FIXTURE-05: --limit filters entries', async () => {
251
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
252
+
253
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
254
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
255
+
256
+ // With limit 2, should return only 2 entries
257
+ await handlePainEvidence({
258
+ workspace: tmpDir,
259
+ limit: 2,
260
+ json: true,
261
+ });
262
+
263
+ const jsonCall = logSpy.mock.calls.find((call) => {
264
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
265
+ });
266
+ expect(jsonCall).toBeDefined();
267
+ const output = JSON.parse(jsonCall![0] as string);
268
+ expect(output.count).toBe(2);
269
+ expect(output.decisions.length).toBe(2);
270
+
271
+ logSpy.mockRestore();
272
+ exitSpy.mockRestore();
273
+ });
274
+ });
275
+
276
+ // ── Commander Registration Tests ───────────────────────────────────────────
277
+
278
+ describe('Commander wiring for pd pain evidence', () => {
279
+ function createEvidenceProgram(): { program: Command; capturedOpts: Record<string, unknown> } {
280
+ const program = new Command();
281
+ program.exitOverride();
282
+ const capturedOpts: Record<string, unknown> = {};
283
+
284
+ program
285
+ .command('pain')
286
+ .command('evidence')
287
+ .option('-w, --workspace <path>', 'Workspace directory')
288
+ .option('-l, --limit <number>', 'Max entries to show', parseInt)
289
+ .option('--json', 'Output raw JSON')
290
+ .action(async (opts) => {
291
+ Object.assign(capturedOpts, opts);
292
+ });
293
+
294
+ return { program, capturedOpts };
295
+ }
296
+
297
+ it('CMD-01: --workspace sets opts.workspace', async () => {
298
+ const { program, capturedOpts } = createEvidenceProgram();
299
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence', '--workspace', '/tmp/ws']);
300
+ expect(capturedOpts.workspace).toBe('/tmp/ws');
301
+ });
302
+
303
+ it('CMD-02: -w short form sets opts.workspace', async () => {
304
+ const { program, capturedOpts } = createEvidenceProgram();
305
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence', '-w', '/tmp/ws']);
306
+ expect(capturedOpts.workspace).toBe('/tmp/ws');
307
+ });
308
+
309
+ it('CMD-03: --limit sets opts.limit as number', async () => {
310
+ const { program, capturedOpts } = createEvidenceProgram();
311
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence', '--limit', '5']);
312
+ expect(capturedOpts.limit).toBe(5);
313
+ });
314
+
315
+ it('CMD-04: -l short form sets opts.limit', async () => {
316
+ const { program, capturedOpts } = createEvidenceProgram();
317
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence', '-l', '10']);
318
+ expect(capturedOpts.limit).toBe(10);
319
+ });
320
+
321
+ it('CMD-05: default (no args) → opts.limit undefined', async () => {
322
+ const { program, capturedOpts } = createEvidenceProgram();
323
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence']);
324
+ expect(capturedOpts.limit).toBeUndefined();
325
+ });
326
+
327
+ it('CMD-06: --json sets opts.json === true', async () => {
328
+ const { program, capturedOpts } = createEvidenceProgram();
329
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence', '--json']);
330
+ expect(capturedOpts.json).toBe(true);
331
+ });
332
+
333
+ it('CMD-07: no --json → opts.json undefined', async () => {
334
+ const { program, capturedOpts } = createEvidenceProgram();
335
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence']);
336
+ expect(capturedOpts.json).toBeUndefined();
337
+ });
338
+
339
+ it('CMD-08: --limit with non-numeric value → NaN', async () => {
340
+ const { program, capturedOpts } = createEvidenceProgram();
341
+ await program.parseAsync(['node', 'pd', 'pain', 'evidence', '--limit', 'abc']);
342
+ expect(capturedOpts.limit).toBeNaN();
343
+ });
344
+ });
345
+
346
+ // ── Limit Validation Tests ─────────────────────────────────────────────────
347
+
348
+ describe('pain evidence — limit validation', () => {
349
+ it('INVALID-01: --limit 0 fails loud with reason + nextAction (JSON)', async () => {
350
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
351
+
352
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
353
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
354
+
355
+ await handlePainEvidence({
356
+ workspace: '/nonexistent',
357
+ limit: 0,
358
+ json: true,
359
+ });
360
+
361
+ const jsonCall = logSpy.mock.calls.find((call) => {
362
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
363
+ });
364
+ expect(jsonCall).toBeDefined();
365
+ const output = JSON.parse(jsonCall![0] as string);
366
+ expect(output.error).toBeDefined();
367
+ expect(output.error.status).toBe('refused');
368
+ expect(output.error.reason).toContain('invalid_limit');
369
+ expect(output.error.reason).toContain('0');
370
+ expect(output.error.nextAction).toBeDefined();
371
+ expect(exitSpy).toHaveBeenCalledWith(1);
372
+ // Guard: no readRecentDecisions after invalid limit
373
+ expect(output.decisions).toBeUndefined();
374
+ expect(output.count).toBe(0);
375
+ // Exactly one JSON output, no second JSON from readRecentDecisions
376
+ const jsonCountAfterInvalid = logSpy.mock.calls.filter((call) => {
377
+ try { return typeof call[0] === 'string' && JSON.parse(call[0]); } catch { return false; }
378
+ }).length;
379
+ expect(jsonCountAfterInvalid).toBe(1);
380
+
381
+ logSpy.mockRestore();
382
+ exitSpy.mockRestore();
383
+ });
384
+
385
+ it('INVALID-02: --limit -1 fails loud (JSON)', async () => {
386
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
387
+
388
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
389
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
390
+
391
+ await handlePainEvidence({
392
+ workspace: '/nonexistent',
393
+ limit: -1,
394
+ json: true,
395
+ });
396
+
397
+ const jsonCall = logSpy.mock.calls.find((call) => {
398
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
399
+ });
400
+ expect(jsonCall).toBeDefined();
401
+ const output = JSON.parse(jsonCall![0] as string);
402
+ expect(output.error.status).toBe('refused');
403
+ expect(output.error.reason).toContain('invalid_limit');
404
+ expect(exitSpy).toHaveBeenCalledWith(1);
405
+
406
+ logSpy.mockRestore();
407
+ exitSpy.mockRestore();
408
+ });
409
+
410
+ it('INVALID-03: --limit 10001 fails loud (JSON)', async () => {
411
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
412
+
413
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
414
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
415
+
416
+ await handlePainEvidence({
417
+ workspace: '/nonexistent',
418
+ limit: 10001,
419
+ json: true,
420
+ });
421
+
422
+ const jsonCall = logSpy.mock.calls.find((call) => {
423
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
424
+ });
425
+ expect(jsonCall).toBeDefined();
426
+ const output = JSON.parse(jsonCall![0] as string);
427
+ expect(output.error.status).toBe('refused');
428
+ expect(exitSpy).toHaveBeenCalledWith(1);
429
+
430
+ logSpy.mockRestore();
431
+ exitSpy.mockRestore();
432
+ });
433
+
434
+ it('INVALID-04: non-integer limit fails loud (JSON)', async () => {
435
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
436
+
437
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
438
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
439
+
440
+ await handlePainEvidence({
441
+ workspace: '/nonexistent',
442
+ limit: 3.5,
443
+ json: true,
444
+ });
445
+
446
+ const jsonCall = logSpy.mock.calls.find((call) => {
447
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
448
+ });
449
+ expect(jsonCall).toBeDefined();
450
+ const output = JSON.parse(jsonCall![0] as string);
451
+ expect(output.error.status).toBe('refused');
452
+ expect(output.error.reason).toContain('invalid_limit');
453
+ expect(exitSpy).toHaveBeenCalledWith(1);
454
+
455
+ logSpy.mockRestore();
456
+ exitSpy.mockRestore();
457
+ });
458
+
459
+ it('INVALID-05: --limit 0 fails loud with human-readable error', async () => {
460
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
461
+
462
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
463
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
464
+
465
+ await handlePainEvidence({
466
+ workspace: '/nonexistent',
467
+ limit: 0,
468
+ json: false,
469
+ });
470
+
471
+ const allErrors = errorSpy.mock.calls.map(c => c[0]).join('\n');
472
+ expect(allErrors).toContain('invalid_limit');
473
+ expect(allErrors).toContain('Next:');
474
+ expect(exitSpy).toHaveBeenCalledWith(1);
475
+
476
+ errorSpy.mockRestore();
477
+ exitSpy.mockRestore();
478
+ });
479
+ });
@@ -718,6 +718,15 @@ describe('Commander wiring for pd pain retry', () => {
718
718
  .requiredOption('-p, --pain-id <painId>', 'Pain ID')
719
719
  .option('-w, --workspace <path>', 'Workspace directory')
720
720
  .option('-r, --runtime <kind>', 'Runtime kind')
721
+ .option('--openclaw-local', 'Use local OpenClaw')
722
+ .option('--openclaw-gateway', 'Use gateway OpenClaw')
723
+ .option('-a, --agent <agentId>', 'Agent ID')
724
+ .option('--provider <name>', 'LLM provider')
725
+ .option('--model <id>', 'Model ID')
726
+ .option('--apiKeyEnv <name>', 'API key env var')
727
+ .option('--baseUrl <url>', 'Custom base URL')
728
+ .option('--maxRetries <n>', 'Max retries', parseInt)
729
+ .option('--timeoutMs <ms>', 'Timeout ms', parseInt)
721
730
  .option('--force', 'Force retry of succeeded task')
722
731
  .option('--json', 'Output raw JSON')
723
732
  .action(async (opts) => {
@@ -769,4 +778,59 @@ describe('Commander wiring for pd pain retry', () => {
769
778
  await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--runtime', 'pi-ai']);
770
779
  expect(capturedOpts.runtime).toBe('pi-ai');
771
780
  });
781
+
782
+ // REGRESSION: PRI-337 — all options must route through pain retry
783
+ it('CMD-08: --baseUrl sets opts.baseUrl', async () => {
784
+ const { program, capturedOpts } = createPainRetryProgram();
785
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--baseUrl', 'https://custom.api.com']);
786
+ expect(capturedOpts.baseUrl).toBe('https://custom.api.com');
787
+ });
788
+
789
+ it('CMD-09: --maxRetries sets opts.maxRetries as number', async () => {
790
+ const { program, capturedOpts } = createPainRetryProgram();
791
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--maxRetries', '5']);
792
+ expect(capturedOpts.maxRetries).toBe(5);
793
+ });
794
+
795
+ it('CMD-10: --timeoutMs sets opts.timeoutMs as number', async () => {
796
+ const { program, capturedOpts } = createPainRetryProgram();
797
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--timeoutMs', '60000']);
798
+ expect(capturedOpts.timeoutMs).toBe(60000);
799
+ });
800
+
801
+ it('CMD-11: --provider sets opts.provider', async () => {
802
+ const { program, capturedOpts } = createPainRetryProgram();
803
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--provider', 'openrouter']);
804
+ expect(capturedOpts.provider).toBe('openrouter');
805
+ });
806
+
807
+ it('CMD-12: --model sets opts.model', async () => {
808
+ const { program, capturedOpts } = createPainRetryProgram();
809
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--model', 'anthropic/claude-sonnet-4']);
810
+ expect(capturedOpts.model).toBe('anthropic/claude-sonnet-4');
811
+ });
812
+
813
+ it('CMD-13: --apiKeyEnv sets opts.apiKeyEnv', async () => {
814
+ const { program, capturedOpts } = createPainRetryProgram();
815
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--apiKeyEnv', 'OPENROUTER_KEY']);
816
+ expect(capturedOpts.apiKeyEnv).toBe('OPENROUTER_KEY');
817
+ });
818
+
819
+ it('CMD-14: -a (--agent) sets opts.agent', async () => {
820
+ const { program, capturedOpts } = createPainRetryProgram();
821
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '-a', 'main']);
822
+ expect(capturedOpts.agent).toBe('main');
823
+ });
824
+
825
+ it('CMD-15: --openclaw-local sets opts.openclawLocal === true', async () => {
826
+ const { program, capturedOpts } = createPainRetryProgram();
827
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--openclaw-local']);
828
+ expect(capturedOpts.openclawLocal).toBe(true);
829
+ });
830
+
831
+ it('CMD-16: --openclaw-gateway sets opts.openclawGateway === true', async () => {
832
+ const { program, capturedOpts } = createPainRetryProgram();
833
+ await program.parseAsync(['node', 'pd', 'pain', 'retry', '--pain-id', 'abc', '--openclaw-gateway']);
834
+ expect(capturedOpts.openclawGateway).toBe(true);
835
+ });
772
836
  });