@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,210 @@
1
+ /**
2
+ * pd pain evidence command — PEAT-B2
3
+ *
4
+ * Query recent admission/trigger decisions from PD logs.
5
+ * This is a read-only diagnostic command — no side effects.
6
+ *
7
+ * Usage:
8
+ * pd pain evidence [--workspace <path>] [--limit N] [--json]
9
+ *
10
+ * Shows the most recent TRIGGER_DECISION log entries.
11
+ */
12
+
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
16
+
17
+ interface EvidenceOptions {
18
+ workspace?: string;
19
+ limit?: number;
20
+ json?: boolean;
21
+ }
22
+
23
+ interface TriggerDecisionEntry {
24
+ timestamp: string;
25
+ outcome: string;
26
+ sourceKind: string;
27
+ reason: string;
28
+ nextAction: string;
29
+ tool?: string;
30
+ path?: string;
31
+ painId?: string;
32
+ score?: number;
33
+ sessionId?: string;
34
+ }
35
+
36
+ /**
37
+ * Get the memory/logs directory for a workspace.
38
+ * SystemLogger writes to <workspace>/memory/logs/SYSTEM_YYYY-MM-DD.log.
39
+ */
40
+ function getLogDir(workspaceDir: string): string {
41
+ return path.join(workspaceDir, 'memory', 'logs');
42
+ }
43
+
44
+ /**
45
+ * Parse TRIGGER_DECISION entries from a log file.
46
+ * Returns entries in reverse chronological order (newest first).
47
+ */
48
+ function parseTriggerDecisions(logContent: string): TriggerDecisionEntry[] {
49
+ const entries: TriggerDecisionEntry[] = [];
50
+ const lines = logContent.split('\n');
51
+
52
+ for (const line of lines) {
53
+ if (!line.includes('TRIGGER_DECISION')) continue;
54
+
55
+ // Extract JSON payload from log line
56
+ const jsonStart = line.indexOf('{');
57
+ if (jsonStart === -1) continue;
58
+
59
+ try {
60
+ const payload = JSON.parse(line.slice(jsonStart));
61
+ // Extract timestamp from log line prefix.
62
+ // SystemLogger produces two formats:
63
+ // - Plain: "2026-06-08 10:16:00 [INFO] ..."
64
+ // - Bracketed ISO: "[2026-06-08T10:16:00.123Z] [INFO] ..."
65
+ const tsMatch = /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/.exec(line)
66
+ ?? /^\[(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\]/.exec(line);
67
+ entries.push({
68
+ timestamp: tsMatch?.[1] ?? new Date().toISOString(),
69
+ outcome: typeof payload.outcome === 'string' ? payload.outcome : 'unknown',
70
+ sourceKind: typeof payload.sourceKind === 'string' ? payload.sourceKind : 'unknown',
71
+ reason: typeof payload.reason === 'string' ? payload.reason : '',
72
+ nextAction: typeof payload.nextAction === 'string' ? payload.nextAction : '',
73
+ tool: typeof payload.tool === 'string' ? payload.tool : undefined,
74
+ path: typeof payload.path === 'string' ? payload.path : undefined,
75
+ painId: typeof payload.painId === 'string' ? payload.painId : undefined,
76
+ score: typeof payload.score === 'number' ? payload.score : undefined,
77
+ sessionId: typeof payload.sessionId === 'string' ? payload.sessionId : undefined,
78
+ });
79
+ } catch (e) {
80
+ // Skip malformed entries — always log for operator visibility (EP-03)
81
+ console.error(`WARN: Malformed TRIGGER_DECISION entry: ${String(e).slice(0, 100)}`);
82
+ }
83
+ }
84
+
85
+ return entries;
86
+ }
87
+
88
+ /**
89
+ * Read recent trigger decisions from memory/logs files.
90
+ * SystemLogger writes date-stamped files: SYSTEM_YYYY-MM-DD.log.
91
+ */
92
+ function readRecentDecisions(logDir: string, limit: number): TriggerDecisionEntry[] {
93
+ if (!fs.existsSync(logDir)) return [];
94
+
95
+ const logFiles = fs.readdirSync(logDir)
96
+ .filter(f => f.startsWith('SYSTEM_') && f.endsWith('.log'))
97
+ .sort()
98
+ .reverse(); // newest first
99
+
100
+ const allEntries: TriggerDecisionEntry[] = [];
101
+
102
+ for (const logFile of logFiles) {
103
+ if (allEntries.length >= limit) break;
104
+
105
+ const filePath = path.join(logDir, logFile);
106
+ try {
107
+ const content = fs.readFileSync(filePath, 'utf8');
108
+ const entries = parseTriggerDecisions(content);
109
+ // Reverse per-file entries so combined list is newest-first.
110
+ // Log files are already sorted newest-first, but entries within
111
+ // each file are in chronological (oldest-first) order.
112
+ allEntries.push(...entries.reverse());
113
+ } catch (e) {
114
+ // Skip unreadable files — always log for operator visibility (EP-03)
115
+ console.error(`WARN: Could not read log file ${logFile}: ${String(e).slice(0, 100)}`);
116
+ }
117
+ }
118
+
119
+ return allEntries.slice(0, limit);
120
+ }
121
+
122
+ function getOutcomeEmoji(outcome: string): string {
123
+ switch (outcome) {
124
+ case 'diagnosis_created': return '🔧';
125
+ case 'manual_owner_admitted': return '✅';
126
+ case 'evidence_only': return '📋';
127
+ case 'health_only': return '💚';
128
+ case 'cooldown_skipped': return '⏳';
129
+ case 'owner_confirm_required': return '❓';
130
+ case 'refused': return '🚫';
131
+ default: return '❔';
132
+ }
133
+ }
134
+
135
+ function truncate(s: string, maxLen: number): string {
136
+ if (s.length <= maxLen) return s;
137
+ return s.slice(0, maxLen - 3) + '...';
138
+ }
139
+
140
+ export async function handlePainEvidence(opts: EvidenceOptions): Promise<void> {
141
+ const { workspace, limit: rawLimit, json } = opts;
142
+ const workspaceDir = resolveWorkspaceDir(workspace);
143
+ const logDir = getLogDir(workspaceDir);
144
+
145
+ // Validate limit — fail loud for invalid values
146
+ let effectiveLimit = 20;
147
+ if (rawLimit !== undefined) {
148
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 10000) {
149
+ const err = {
150
+ status: 'refused',
151
+ reason: `invalid_limit: limit must be an integer between 1 and 10000, got ${rawLimit}`,
152
+ nextAction: 'Pass --limit with a valid integer (1-10000)',
153
+ };
154
+ const { reason, nextAction } = err;
155
+ if (json) {
156
+ console.log(JSON.stringify({ count: 0, error: err }));
157
+ } else {
158
+ console.error('Error: ' + reason);
159
+ console.error('Next: ' + nextAction);
160
+ }
161
+ process.exit(1);
162
+ return; // guard: test stubs of process.exit continue execution
163
+ }
164
+ effectiveLimit = rawLimit;
165
+ }
166
+
167
+ const decisions = readRecentDecisions(logDir, effectiveLimit);
168
+
169
+ if (json) {
170
+ console.log(JSON.stringify({ count: decisions.length, decisions, searchedPath: path.join(logDir, 'SYSTEM_*.log') }, null, 2));
171
+ return;
172
+ }
173
+
174
+ if (decisions.length === 0) {
175
+ console.log('No trigger decisions found in logs.');
176
+ console.log(`Searched: ${path.join(logDir, 'SYSTEM_*.log')}`);
177
+ console.log('Tip: Enable painEvidenceAdmission feature flag to start recording trigger decisions.');
178
+ return;
179
+ }
180
+
181
+ console.log(`Recent Pain Evidence Admission Decisions (${decisions.length}):`);
182
+ console.log('─'.repeat(80));
183
+
184
+ for (const d of decisions) {
185
+ const outcomeEmoji = getOutcomeEmoji(d.outcome);
186
+ console.log(` ${outcomeEmoji} [${d.timestamp}] ${d.outcome}`);
187
+ console.log(` Source: ${d.sourceKind} | Score: ${d.score ?? 'N/A'}`);
188
+ console.log(` Reason: ${truncate(d.reason, 100)}`);
189
+ if (d.nextAction && d.nextAction !== 'none') {
190
+ console.log(` Next: ${truncate(d.nextAction, 80)}`);
191
+ }
192
+ if (d.tool) console.log(` Tool: ${d.tool} | Path: ${d.path ?? 'N/A'}`);
193
+ console.log();
194
+ }
195
+
196
+ // Summary
197
+ const byOutcome = new Map<string, number>();
198
+ for (const d of decisions) {
199
+ byOutcome.set(d.outcome, (byOutcome.get(d.outcome) ?? 0) + 1);
200
+ }
201
+ console.log('─'.repeat(80));
202
+ console.log('Summary:');
203
+ for (const [outcome, count] of byOutcome) {
204
+ console.log(` ${getOutcomeEmoji(outcome)} ${outcome}: ${count}`);
205
+ }
206
+ }
207
+
208
+ // Export for testing
209
+ // istanbul ignore next
210
+ export { parseTriggerDecisions, getLogDir };
@@ -0,0 +1,177 @@
1
+ /**
2
+ * PRI-334: Runtime UAT guard — command-level integration tests.
3
+ *
4
+ * These tests verify the guard is wired into the real Commander command
5
+ * and that refused paths do NOT call execFileSync (mutation prevention).
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterAll, beforeAll } from 'vitest';
8
+ import { Command } from 'commander';
9
+ import * as path from 'path';
10
+ import * as os from 'os';
11
+
12
+ // ─── Mocks ─────────────────────────────────────────────────────────────────
13
+ const mockExecFileSync = vi.fn();
14
+ vi.mock('child_process', () => ({
15
+ execFileSync: mockExecFileSync,
16
+ }));
17
+
18
+ const { handleRuntimeUat } = await import('./runtime-uat.js');
19
+ const { guardUatWorkspace } = await import('../utils/production-workspace-guard.js');
20
+
21
+ // ─── Test Setup ─────────────────────────────────────────────────────────────
22
+ const capturedStderr: string[] = [];
23
+ const capturedStdout: string[] = [];
24
+ let capturedExitCode: number | null = null;
25
+
26
+ const originalExit = process.exit;
27
+ const originalError = console.error;
28
+ const originalLog = console.log;
29
+ const originalEnv = { ...process.env };
30
+
31
+ beforeAll(() => {
32
+ process.exit = vi.fn(((code?: number) => {
33
+ capturedExitCode = code ?? 0;
34
+ }) as typeof process.exit);
35
+ console.error = vi.fn((...args: unknown[]) => { capturedStderr.push(args.join(' ')); });
36
+ console.log = vi.fn((...args: unknown[]) => { capturedStdout.push(args.join(' ')); });
37
+ });
38
+
39
+ afterAll(() => {
40
+ process.exit = originalExit;
41
+ console.error = originalError;
42
+ console.log = originalLog;
43
+ process.env = originalEnv;
44
+ });
45
+
46
+ beforeEach(() => {
47
+ capturedExitCode = null;
48
+ capturedStderr.length = 0;
49
+ capturedStdout.length = 0;
50
+ mockExecFileSync.mockReset();
51
+ process.env = { ...process.env, MINIMAX_CN_API_KEY: 'test-key-for-pri-334' };
52
+ });
53
+
54
+ // ─── Tests ──────────────────────────────────────────────────────────────────
55
+ describe('PRI-334: production workspace refusal', () => {
56
+ it('refuses D: workspace with exit code 1', async () => {
57
+ mockExecFileSync.mockClear();
58
+ await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1 });
59
+ expect(capturedExitCode).toBe(1);
60
+ expect(mockExecFileSync).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('refuses C: workspace with exit code 1', async () => {
64
+ mockExecFileSync.mockClear();
65
+ await handleRuntimeUat({ workspace: 'C:\\.openclaw\\workspace', count: 1 });
66
+ expect(capturedExitCode).toBe(1);
67
+ expect(mockExecFileSync).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('refuses production workspace subdirectory', async () => {
71
+ mockExecFileSync.mockClear();
72
+ await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace\\sub\\path', count: 1 });
73
+ expect(capturedExitCode).toBe(1);
74
+ expect(mockExecFileSync).not.toHaveBeenCalled();
75
+ });
76
+ });
77
+
78
+ describe('PRI-334: allowed paths (guard level)', () => {
79
+ it('allows temp workspace', () => {
80
+ const tempPath = path.join(os.tmpdir(), 'pd-test-' + Date.now());
81
+ expect(guardUatWorkspace(tempPath, 'test').refused).toBe(false);
82
+ });
83
+
84
+ it('allows workspace-test sibling (ERR-030)', () => {
85
+ expect(guardUatWorkspace('D:\\.openclaw\\workspace-test', 'test').refused).toBe(false);
86
+ });
87
+
88
+ it('allows workspace-backup sibling (ERR-030)', () => {
89
+ expect(guardUatWorkspace('D:\\.openclaw\\workspace-backup', 'test').refused).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('PRI-334: JSON output (EP-04)', () => {
94
+ it('outputs single JSON object with reason and nextAction', async () => {
95
+ mockExecFileSync.mockClear();
96
+ capturedStdout.length = 0;
97
+ await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1, json: true });
98
+ expect(capturedExitCode).toBe(1);
99
+ expect(mockExecFileSync).not.toHaveBeenCalled();
100
+
101
+ const jsonOutput = capturedStdout.join('');
102
+ const parsed = JSON.parse(jsonOutput);
103
+ expect(parsed).toMatchObject({
104
+ status: 'refused', reason: expect.any(String), nextAction: expect.any(String),
105
+ workspace: expect.any(String), isProduction: true,
106
+ });
107
+ expect(Array.isArray(parsed)).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe('PRI-334: escape hatch', () => {
112
+ it('warns when allow-production-workspace-for-uat flag is set', async () => {
113
+ mockExecFileSync.mockClear();
114
+ mockExecFileSync.mockImplementation(() => '{"painId":"test","taskId":"t1","runId":"r1","candidateIds":["c1"],"ledgerEntryIds":["l1"]}');
115
+ capturedStderr.length = 0;
116
+ capturedExitCode = null;
117
+
118
+ await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1, allowProductionWorkspaceForUat: true });
119
+ const stderrText = capturedStderr.join('\n');
120
+ expect(stderrText).toContain('WARNING');
121
+ expect(stderrText).toContain('--allow-production-workspace-for-uat');
122
+ });
123
+ });
124
+
125
+ describe('PRI-334: mutation prevention', () => {
126
+ it('does not call execFileSync after guard refusal', async () => {
127
+ mockExecFileSync.mockClear();
128
+ await handleRuntimeUat({ workspace: 'D:\\.openclaw\\workspace', count: 1 });
129
+ expect(mockExecFileSync).not.toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ describe('PRI-334: Commander flag wiring (ERR-063)', () => {
134
+ it('parses --allow-production-workspace-for-uat', () => {
135
+ const program = new Command();
136
+ const rt = program.command('runtime');
137
+ rt.command('uat')
138
+ .option('--allow-production-workspace-for-uat', 'Escape hatch')
139
+ .action(() => { /* noop */ });
140
+ const cmd = rt.commands.find((c) => c.name() === 'uat') as Command;
141
+ const opt = cmd.options.find((o) => o.long === '--allow-production-workspace-for-uat');
142
+ expect(opt).toBeDefined();
143
+ expect(opt?.long).toBe('--allow-production-workspace-for-uat');
144
+ });
145
+
146
+ it('negated form is NOT registered', () => {
147
+ const program = new Command();
148
+ const rt = program.command('runtime');
149
+ rt.command('uat')
150
+ .option('--allow-production-workspace-for-uat', 'Escape hatch')
151
+ .action(() => { /* noop */ });
152
+ const cmd = rt.commands.find((c) => c.name() === 'uat') as Command;
153
+ const noForm = cmd.options.find((o) => o.long === '--no-allow-production-workspace-for-uat');
154
+ expect(noForm).toBeUndefined();
155
+ });
156
+ });
157
+
158
+ describe('PRI-334: shouldExitWithError exit path (EP-04)', () => {
159
+ it('exits 1 and does not print ALL CHECKS PASSED when shouldExitWithError is true', async () => {
160
+ const tempWorkspace = path.join(os.tmpdir(), 'pd-exit-test-' + Date.now());
161
+ mockExecFileSync.mockClear();
162
+ // Make all iterations fail (execFileSync throws) so shouldExitWithError returns true
163
+ mockExecFileSync.mockRejectedValue(new Error('simulated failure'));
164
+ capturedStderr.length = 0;
165
+ capturedExitCode = null;
166
+
167
+ await handleRuntimeUat({
168
+ workspace: tempWorkspace,
169
+ count: 1,
170
+ minSuccessRate: 1.0,
171
+ });
172
+
173
+ expect(capturedExitCode).toBe(1);
174
+ const stderrText = capturedStderr.join('\n');
175
+ expect(stderrText).not.toContain('ALL CHECKS PASSED');
176
+ });
177
+ });
@@ -17,6 +17,12 @@ import { execFileSync } from 'child_process';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import * as path from 'path';
19
19
  import * as fs from 'fs';
20
+ import {
21
+ guardUatWorkspace,
22
+ formatGuardRefusal,
23
+ type GuardResult,
24
+ type GuardRefusal,
25
+ } from '../utils/production-workspace-guard.js';
20
26
 
21
27
  // ── Types ────────────────────────────────────────────────────────────────────
22
28
 
@@ -25,6 +31,7 @@ interface UatOptions {
25
31
  count?: number;
26
32
  minSuccessRate?: number;
27
33
  json?: boolean;
34
+ allowProductionWorkspaceForUat?: boolean;
28
35
  }
29
36
 
30
37
  interface PainRecordResult {
@@ -73,6 +80,8 @@ export function parseUatArgs(argv: string[]): UatOptions {
73
80
  } else if (argv[i] === '--min-success-rate') {
74
81
  const rate = parseFloat(argv[++i] ?? '1.0');
75
82
  args.minSuccessRate = isNaN(rate) ? 1.0 : rate;
83
+ } else if (argv[i] === '--allow-production-workspace-for-uat') {
84
+ args.allowProductionWorkspaceForUat = true;
76
85
  }
77
86
  }
78
87
  return args;
@@ -153,8 +162,6 @@ interface IterationConfig {
153
162
  export function runUatIteration(config: IterationConfig): PainRecordResult {
154
163
  const { iteration, reason, workspace, timeoutMs = 300_000 } = config;
155
164
  const iterStart = Date.now();
156
-
157
- // eslint-disable-next-line @typescript-eslint/init-declarations
158
165
  let recordOutput: string;
159
166
  try {
160
167
  recordOutput = pd(['pain', 'record', '--reason', reason, '--score', '85', '--source', 'manual', '--json'], workspace, timeoutMs);
@@ -173,7 +180,6 @@ export function runUatIteration(config: IterationConfig): PainRecordResult {
173
180
  }
174
181
 
175
182
  const wallTimeMs = Date.now() - iterStart;
176
- // eslint-disable-next-line @typescript-eslint/init-declarations
177
183
  let parsed: Record<string, unknown>;
178
184
  try {
179
185
  parsed = parseJsonOutput(recordOutput) as Record<string, unknown>;
@@ -190,7 +196,6 @@ export function runUatIteration(config: IterationConfig): PainRecordResult {
190
196
  };
191
197
  }
192
198
 
193
- // eslint-disable-next-line @typescript-eslint/init-declarations
194
199
  let auditStatus: string;
195
200
  try {
196
201
  const auditOut = pd(['candidate', 'audit', '--json'], workspace, 30_000);
@@ -276,6 +281,37 @@ export async function handleRuntimeUat(opts: UatOptions): Promise<void> {
276
281
  if (!workspace) {
277
282
  console.error('Error: --workspace <path> is required');
278
283
  process.exit(1);
284
+ return;
285
+ }
286
+
287
+ // PRI-334: Guard against writing to production workspace
288
+ const guardResult: GuardResult = guardUatWorkspace(workspace, 'pd runtime uat');
289
+
290
+ if (guardResult.refused && !opts.allowProductionWorkspaceForUat) {
291
+ // Fail loud with structured reason and nextAction (EP-03/EP-04)
292
+ const refused: GuardRefusal = guardResult;
293
+ const refusalOutput = formatGuardRefusal(
294
+ refused,
295
+ 'pd runtime uat',
296
+ !!opts.json
297
+ );
298
+
299
+ if (opts.json) {
300
+ console.log(refusalOutput);
301
+ } else {
302
+ console.error(refusalOutput);
303
+ }
304
+
305
+ process.exit(1);
306
+ return;
307
+ }
308
+
309
+ if (guardResult.refused && opts.allowProductionWorkspaceForUat) {
310
+ // Escape hatch used: warn but allow
311
+ console.error('[pd-cli] WARNING: --allow-production-workspace-for-uat is set.');
312
+ console.error(' Test/synthetic data will be written to your production workspace.');
313
+ console.error(' This is not recommended and may pollute your real PD state.');
314
+ console.error('');
279
315
  }
280
316
 
281
317
  console.error(`[${new Date().toISOString()}] Runtime V2 Chain UAT — workspace: ${workspace}, count: ${count}`);
@@ -284,6 +320,7 @@ export async function handleRuntimeUat(opts: UatOptions): Promise<void> {
284
320
  if (!process.env.MINIMAX_CN_API_KEY) {
285
321
  console.error('Error: MINIMAX_CN_API_KEY environment variable not set');
286
322
  process.exit(1);
323
+ return;
287
324
  }
288
325
 
289
326
  const results: PainRecordResult[] = [];
@@ -332,6 +369,7 @@ export async function handleRuntimeUat(opts: UatOptions): Promise<void> {
332
369
  console.error(`FAIL: successRate=${summary.successRate} (threshold: ${minSuccessRate}) ` +
333
370
  `ledger=${summary.ledgerConsistencyOk} candidates=${summary.allHaveCandidates} ledger=${summary.allHaveLedger}`);
334
371
  process.exit(1);
372
+ return;
335
373
  }
336
374
 
337
375
  console.error('');
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { Command } from 'commander';
10
10
  import { handlePainRecord } from './commands/pain-record.js';
11
11
  import { handlePainRetry } from './commands/pain-retry.js';
12
+ import { handlePainEvidence } from './commands/pain-evidence.js';
12
13
  import { handleSamplesList } from './commands/samples-list.js';
13
14
  import { handleSamplesReview } from './commands/samples-review.js';
14
15
  import { handleEvolutionTasksList } from './commands/evolution-tasks-list.js';
@@ -98,6 +99,16 @@ painCmd
98
99
  await handlePainRetry(opts);
99
100
  });
100
101
 
102
+ painCmd
103
+ .command('evidence')
104
+ .description('Show recent pain admission/trigger decisions (PEAT-B2)')
105
+ .option('-w, --workspace <path>', 'Workspace directory')
106
+ .option('-l, --limit <number>', 'Max entries to show (default: 20)', parseInt)
107
+ .option('--json', 'Output raw JSON')
108
+ .action(async (opts) => {
109
+ await handlePainEvidence(opts);
110
+ });
111
+
101
112
  const samplesCmd = program
102
113
  .command('samples')
103
114
  .description('Correction sample management');
@@ -475,12 +486,14 @@ runtimeCmd
475
486
  .option('--count <n>', 'Number of iterations (default: 5, max: 50)', parseInt)
476
487
  .option('--min-success-rate <rate>', 'Minimum success rate threshold (default: 1.0)', parseFloat)
477
488
  .option('--json', 'Output machine-readable JSON summary')
489
+ .option('--allow-production-workspace-for-uat', 'DANGEROUS: Allow UAT to write to production workspace (NOT RECOMMENDED)')
478
490
  .action(async (opts) => {
479
491
  await handleRuntimeUat({
480
492
  workspace: opts.workspace,
481
493
  count: opts.count,
482
494
  minSuccessRate: opts.minSuccessRate,
483
495
  json: opts.json,
496
+ allowProductionWorkspaceForUat: opts.allowProductionWorkspaceForUat,
484
497
  });
485
498
  });
486
499
 
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import {
5
+ isProductionWorkspace,
6
+ guardUatWorkspace,
7
+ getSafeUatWorkspacePath,
8
+ formatGuardRefusal,
9
+ type GuardRefusal,
10
+ } from './production-workspace-guard.js';
11
+
12
+ const PRODUCTION_PATHS = [
13
+ 'D:\\.openclaw\\workspace',
14
+ 'C:\\.openclaw\\workspace',
15
+ 'C:\\Users\\Administrator\\.openclaw\\workspace',
16
+ path.join(os.homedir(), '.openclaw', 'workspace'),
17
+ ];
18
+
19
+ const SAFE_PATHS = [
20
+ 'D:\\.openclaw\\workspace-test',
21
+ 'D:\\.openclaw\\workspace-backup',
22
+ path.join(os.tmpdir(), 'pd-uat-workspace'),
23
+ path.join(os.tmpdir(), 'pd-test-any'),
24
+ 'C:\\completely-unrelated\\work',
25
+ ];
26
+
27
+ describe('isProductionWorkspace', () => {
28
+ it.each(PRODUCTION_PATHS)('detects production: %s', (prodPath) => {
29
+ expect(isProductionWorkspace(path.resolve(prodPath))).toBe(true);
30
+ });
31
+ it.each(SAFE_PATHS)('allows safe: %s', (safePath) => {
32
+ expect(isProductionWorkspace(path.resolve(safePath))).toBe(false);
33
+ });
34
+ it('detects descendant', () => {
35
+ expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace\\sub\\child'))).toBe(true);
36
+ });
37
+ it('rejects sibling workspace-test (ERR-030)', () => {
38
+ expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace-test'))).toBe(false);
39
+ });
40
+ it('rejects sibling workspace-backup (ERR-030)', () => {
41
+ expect(isProductionWorkspace(path.resolve('D:\\.openclaw\\workspace-backup'))).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('guardUatWorkspace', () => {
46
+ describe('refused', () => {
47
+ it.each(PRODUCTION_PATHS)('refuses production: %s', (prodPath) => {
48
+ const r = guardUatWorkspace(prodPath, 'test');
49
+ expect(r.refused).toBe(true);
50
+ if (r.refused) {
51
+ expect(r.reason).toContain('UAT/runtime test commands are not allowed');
52
+ expect(r.nextAction).toContain('temporary workspace');
53
+ }
54
+ });
55
+ it('refuses descendant', () => {
56
+ expect(guardUatWorkspace('D:\\.openclaw\\workspace\\subdir', 'test').refused).toBe(true);
57
+ });
58
+ });
59
+ describe('allowed', () => {
60
+ it.each(SAFE_PATHS)('allows safe: %s', (safePath) => {
61
+ const r = guardUatWorkspace(safePath, 'test');
62
+ expect(r.refused).toBe(false);
63
+ });
64
+ it.each(['D:\\.openclaw\\workspace-test', 'D:\\.openclaw\\workspace-backup'])(
65
+ 'allows sibling: %s (ERR-030)', (p) => {
66
+ expect(guardUatWorkspace(p, 'test').refused).toBe(false);
67
+ });
68
+ });
69
+ });
70
+
71
+ describe('JSON output (EP-04)', () => {
72
+ it('outputs single object with reason and nextAction', () => {
73
+ const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
74
+ const json = formatGuardRefusal(r as GuardRefusal, 'test', true);
75
+ const parsed = JSON.parse(json);
76
+ expect(parsed).toMatchObject({
77
+ status: 'refused', reason: expect.any(String), nextAction: expect.any(String),
78
+ workspace: expect.any(String), isProduction: true,
79
+ });
80
+ expect(Array.isArray(parsed)).toBe(false);
81
+ });
82
+ it('no console prefixes in JSON', () => {
83
+ const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
84
+ const json = formatGuardRefusal(r as GuardRefusal, 'test', true);
85
+ expect(json).not.toContain('[pd-cli]');
86
+ expect(json).not.toContain('ERROR:');
87
+ expect(json.trim().startsWith('{')).toBe(true);
88
+ });
89
+ });
90
+
91
+ describe('text output (EP-03)', () => {
92
+ it('includes reason and nextAction', () => {
93
+ const r = guardUatWorkspace('D:\\.openclaw\\workspace', 'test');
94
+ const text = formatGuardRefusal(r as GuardRefusal, 'test', false);
95
+ expect(text).toContain('Reason:');
96
+ expect(text).toContain('Next Action:');
97
+ });
98
+ });
99
+
100
+ describe('getSafeUatWorkspacePath', () => {
101
+ it('returns deterministic temp path', () => {
102
+ const p1 = getSafeUatWorkspacePath();
103
+ const p2 = getSafeUatWorkspacePath();
104
+ expect(p1).toBe(p2);
105
+ expect(p1).toContain(os.tmpdir());
106
+ expect(p1).toContain('pd-uat-workspace');
107
+ });
108
+ });