@principles/pd-cli 1.145.2 → 1.146.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.
@@ -21,21 +21,31 @@ vi.mock('fs', () => ({
21
21
  }));
22
22
 
23
23
  vi.mock('../../src/commands/build-trajectory-evidence.js', () => ({
24
- buildTrajectoryEvidenceFromDb: vi.fn().mockReturnValue([
25
- { sourceRef: 'owner_reported:cli', note: 'No session context available' },
26
- ]),
24
+ acquireTrajectoryEvidenceFromDb: vi.fn().mockReturnValue({
25
+ status: 'available',
26
+ entries: [
27
+ { sourceRef: 'owner_message:2026-01-01T10:00:00Z', note: 'Owner correction' },
28
+ { sourceRef: 'agent_turn:2026-01-01T10:01:00Z', note: 'assistant evidence' },
29
+ ],
30
+ }),
27
31
  }));
28
32
 
29
- vi.mock('@principles/core/runtime-v2', () => ({
30
- PainToPrincipleService: vi.fn().mockImplementation(function() {
31
- return {
32
- recordPain: vi.fn(async (input: PainToPrincipleInput) => {
33
- lastRecordPainInput = input;
34
- return mockRecordPainResult;
35
- }),
36
- };
37
- }),
38
- PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
33
+ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
34
+ // PRI-642 review blocker 1: the CLI now evaluates ingress semantics via
35
+ // the REAL core evaluatePainIngress (the shared authority) — only the
36
+ // service/IO classes are mocked.
37
+ const actual = await importOriginal<typeof import('@principles/core/runtime-v2')>();
38
+ return {
39
+ ...actual,
40
+ PainToPrincipleService: vi.fn().mockImplementation(function() {
41
+ return {
42
+ recordPain: vi.fn(async (input: PainToPrincipleInput) => {
43
+ lastRecordPainInput = input;
44
+ return mockRecordPainResult;
45
+ }),
46
+ };
47
+ }),
48
+ PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
39
49
  computeEffectivePdConfig: vi.fn().mockReturnValue({
40
50
  runtimeKind: 'pi-ai',
41
51
  provider: 'test-provider',
@@ -58,7 +68,8 @@ vi.mock('@principles/core/runtime-v2', () => ({
58
68
  isBuiltinPiAiProvider: vi.fn().mockReturnValue(true),
59
69
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
60
70
  isFeatureEnabled: vi.fn().mockReturnValue(false),
61
- }));
71
+ };
72
+ });
62
73
 
63
74
  vi.mock('../../src/services/pd-config-loader.js', () => ({
64
75
  loadPdConfig: vi.fn().mockReturnValue({
@@ -77,8 +88,9 @@ vi.mock('../../src/services/pd-config-loader.js', () => ({
77
88
  }));
78
89
 
79
90
  import { handlePainRecord } from '../../src/commands/pain-record.js';
80
- import { isBuiltinPiAiProvider } from '@principles/core/runtime-v2';
81
- import type { PainToPrincipleOutput, PainToPrincipleInput, FailureCategory } from '@principles/core/runtime-v2';
91
+ import { isBuiltinPiAiProvider, type PainToPrincipleOutput, type PainToPrincipleInput } from '@principles/core/runtime-v2';
92
+ import type { FailureCategory } from '@principles/core/runtime-v2';
93
+ import { acquireTrajectoryEvidenceFromDb } from '../../src/commands/build-trajectory-evidence.js';
82
94
 
83
95
  // ── Helpers ─────────────────────────────────────────────────────────────────
84
96
 
@@ -351,51 +363,207 @@ describe('pd pain record', () => {
351
363
  expect(lastRecordPainInput!.source).toBe('ci');
352
364
  expect(lastRecordPainInput!.reason).toBe('test pain');
353
365
  expect(lastRecordPainInput!.score).toBe(90);
354
- expect(lastRecordPainInput!.sessionId).toBe('cli');
366
+ // PRI-642: no --session means an explicit unbound Owner report — the
367
+ // 'cli' sentinel session must NOT be fabricated (SPEC §7.4).
368
+ expect(lastRecordPainInput!.sessionId).toBeUndefined();
355
369
  expect(lastRecordPainInput!.agentId).toBe('pd-cli');
356
- // PRI-341: evidence field is now always provided
357
- expect(lastRecordPainInput!.evidence).toBeTruthy();
358
- expect(lastRecordPainInput!.evidence!.length).toBeGreaterThan(0);
370
+ // …and no placeholder evidence entry may be submitted.
371
+ expect(lastRecordPainInput!.evidence).toEqual([]);
372
+ expect(lastRecordPainInput!.recordObservability).toBe(false);
373
+ expect(lastRecordPainInput!.provenance).toBe('owner_reported_no_host_trace');
359
374
  });
360
375
 
361
- // ── PRI-341: evidence passthrough and --session flag ──────────────────────
362
-
363
- // 用例 C: recordPain receives non-empty evidence field when session provided
364
- it('C: passes evidence to recordPain when --session is provided', async () => {
365
- // Mock buildTrajectoryEvidenceFromDb to return evidence
366
- const mockEvidence = [
367
- { sourceRef: 'agent_turn:2026-01-01T10:00:00Z', note: 'assistant text evidence' },
368
- ];
369
- vi.doMock('../../src/commands/build-trajectory-evidence.js', () => ({
370
- buildTrajectoryEvidenceFromDb: vi.fn().mockReturnValue(mockEvidence),
371
- }));
376
+ // ── PRI-341 → PRI-642: evidence passthrough and --session flag ─────────────
372
377
 
378
+ // 用例 C: recordPain receives bound provenance + validated evidence when a real session is provided
379
+ it('C: passes bound provenance and evidence to recordPain when --session resolves available', async () => {
373
380
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
374
381
  const exitSpy = mockProcessExit();
375
382
 
376
383
  await handlePainRecord({ reason: 'test pain', session: 'sess-123', json: true });
377
384
 
378
385
  expect(lastRecordPainInput).toBeTruthy();
386
+ expect(acquireTrajectoryEvidenceFromDb).toHaveBeenCalled();
379
387
  expect(lastRecordPainInput!.evidence).toBeTruthy();
380
388
  expect(lastRecordPainInput!.evidence!.length).toBeGreaterThan(0);
389
+ expect(lastRecordPainInput!.evidence![0].sourceRef).not.toBe('owner_reported:cli');
381
390
  expect(lastRecordPainInput!.sessionId).toBe('sess-123');
391
+ // SPEC §7.3: provenance must be derived from the validated result —
392
+ // a real session is host-context-bound, never owner_reported_no_host_trace.
393
+ expect(lastRecordPainInput!.provenance).toBe('host_context_bound');
394
+ // PRI-640/SPEC §8.3: the bound path attributes the OpenClaw trajectory.
395
+ expect(lastRecordPainInput!.hostKind).toBe('openclaw');
396
+ expect(lastRecordPainInput!.recordObservability).toBe(true);
382
397
 
383
398
  logSpy.mockRestore();
384
399
  exitSpy.mockRestore();
385
400
  });
386
401
 
387
- // 用例 C2: without session, evidence field still has a placeholder (not empty)
388
- it('C2: passes placeholder evidence when no --session provided', async () => {
402
+ // 用例 C2 (PRI-642 rewrite): without session, no sentinel session, no
403
+ // placeholder evidence an honest unbound Owner report (SPEC §7.4).
404
+ it('C2: submits an honest unbound report when no --session provided', async () => {
389
405
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
390
406
  const exitSpy = mockProcessExit();
391
407
 
392
408
  await handlePainRecord({ reason: 'test pain', json: true });
393
409
 
394
410
  expect(lastRecordPainInput).toBeTruthy();
395
- expect(lastRecordPainInput!.evidence).toBeTruthy();
396
- expect(lastRecordPainInput!.evidence!.length).toBeGreaterThan(0);
397
- // Default evidence should be a CLI placeholder
398
- expect(lastRecordPainInput!.evidence![0].sourceRef).toBe('owner_reported:cli');
411
+ expect(lastRecordPainInput!.sessionId).toBeUndefined();
412
+ expect(lastRecordPainInput!.evidence).toEqual([]);
413
+ expect(lastRecordPainInput!.recordObservability).toBe(false);
414
+ expect(lastRecordPainInput!.provenance).toBe('owner_reported_no_host_trace');
415
+
416
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
417
+ // SPEC §7.4: disclose context_unbound and recommend --session.
418
+ const warnings = JSON.stringify(jsonOutput);
419
+ expect(warnings).toMatch(/context_unbound/);
420
+ expect(warnings).toMatch(/--session/);
421
+
422
+ logSpy.mockRestore();
423
+ exitSpy.mockRestore();
424
+ });
425
+
426
+ // ── PRI-642 Scope A: explicit-session validation (SPEC §7.3, §12.1.3–12.1.4) ─
427
+
428
+ it('fails with session_not_found before any task/candidate mutation when --session does not exist', async () => {
429
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
430
+ status: 'unavailable',
431
+ reasonCode: 'session_not_found',
432
+ detail: 'session not present in trajectory.db sessions table',
433
+ } as any);
434
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
435
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
436
+ const exitSpy = mockProcessExit();
437
+
438
+ await handlePainRecord({ reason: 'test pain', session: 'missing-session', json: true });
439
+
440
+ // cli-1: exactly one JSON object on stdout
441
+ expect(logSpy).toHaveBeenCalledTimes(1);
442
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
443
+ expect(jsonOutput.status).toBe('failed');
444
+ expect(jsonOutput.reason).toBe('session_not_found');
445
+ expect(jsonOutput.nextAction).toBeTruthy();
446
+ // cli-2/cli-5: execution stopped — no service mutation, exit 1
447
+ expect(lastRecordPainInput).toBeNull();
448
+ expect(exitSpy).toHaveBeenCalledWith(1);
449
+
450
+ logSpy.mockRestore();
451
+ errorSpy.mockRestore();
452
+ exitSpy.mockRestore();
453
+ });
454
+
455
+ it('fails before any mutation on session_not_found even when process.exit is stubbed to a no-op', async () => {
456
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
457
+ status: 'unavailable',
458
+ reasonCode: 'session_not_found',
459
+ detail: 'session not present',
460
+ } as any);
461
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
462
+ const exitSpy = mockProcessExit();
463
+
464
+ await handlePainRecord({ reason: 'test pain', session: 'missing-session', json: true });
465
+
466
+ // cli-2: with process.exit stubbed, control flow must still stop —
467
+ // recordPain was never invoked and stdout carries exactly one object.
468
+ expect(lastRecordPainInput).toBeNull();
469
+ expect(logSpy).toHaveBeenCalledTimes(1);
470
+ logSpy.mockRestore();
471
+ exitSpy.mockRestore();
472
+ });
473
+
474
+ it('fails before mutation on empty_trajectory (CLI never claims host_context_bound without a verified session)', async () => {
475
+ // Per Evidence Over Assumption: the CLI does not own session identity
476
+ // the way the OpenClaw host command context does. Unverified sessions
477
+ // (any acquisition that does not yield 'available') must refuse before
478
+ // any LLM/task/candidate mutation.
479
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
480
+ status: 'unavailable',
481
+ reasonCode: 'empty_trajectory',
482
+ detail: 'session exists but no usable evidence',
483
+ } as any);
484
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
485
+ const exitSpy = mockProcessExit();
486
+
487
+ await handlePainRecord({ reason: 'test pain', session: 'quiet-session', json: true });
488
+
489
+ expect(lastRecordPainInput).toBeNull();
490
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
491
+ expect(jsonOutput.status).toBe('failed');
492
+ expect(jsonOutput.reason).toBe('empty_trajectory');
493
+ expect(exitSpy).toHaveBeenCalledWith(1);
494
+
495
+ logSpy.mockRestore();
496
+ exitSpy.mockRestore();
497
+ });
498
+
499
+ it('fails before mutation on evidence_read_failed (no LLM/no task when the CLI cannot verify binding)', async () => {
500
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
501
+ status: 'unavailable',
502
+ reasonCode: 'evidence_read_failed',
503
+ detail: 'trajectory.db unreadable',
504
+ } as any);
505
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
506
+ const exitSpy = mockProcessExit();
507
+
508
+ await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
509
+
510
+ expect(lastRecordPainInput).toBeNull();
511
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
512
+ expect(jsonOutput.status).toBe('failed');
513
+ expect(jsonOutput.reason).toBe('evidence_read_failed');
514
+ expect(exitSpy).toHaveBeenCalledWith(1);
515
+
516
+ logSpy.mockRestore();
517
+ exitSpy.mockRestore();
518
+ });
519
+
520
+ it('fails before mutation on trajectory_unavailable (CLI cannot fabricate a session)', async () => {
521
+ vi.mocked(acquireTrajectoryEvidenceFromDb).mockReturnValueOnce({
522
+ status: 'unavailable',
523
+ reasonCode: 'trajectory_unavailable',
524
+ detail: 'no trajectory.db at workspace .state',
525
+ } as any);
526
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
527
+ const exitSpy = mockProcessExit();
528
+
529
+ await handlePainRecord({ reason: 'test pain', session: 'sess-x', json: true });
530
+
531
+ expect(lastRecordPainInput).toBeNull();
532
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
533
+ expect(jsonOutput.status).toBe('failed');
534
+ expect(jsonOutput.reason).toBe('trajectory_unavailable');
535
+ expect(exitSpy).toHaveBeenCalledWith(1);
536
+
537
+ logSpy.mockRestore();
538
+ exitSpy.mockRestore();
539
+ });
540
+
541
+ // ── PRI-642 Scope A (SPEC §12.1.6): generated-but-unadmitted candidates must
542
+ // not be reported as completed internalization. ─────────────────────────────
543
+
544
+ it('warns that no candidate was admitted/internalized when all candidates are gated', async () => {
545
+ mockRecordPainResult = {
546
+ ...SUCCEEDED_RESULT,
547
+ candidateIds: ['c1', 'c2', 'c3', 'c4'],
548
+ ledgerEntryIds: [],
549
+ admissionResults: [
550
+ { candidateId: 'c1', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'confidence_below_threshold:0.45<0.50', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
551
+ { candidateId: 'c2', recommendationKind: 'prompt', admission: { decision: 'deferred', reason: 'recommendation_kind_defer_not_actionable', nextAction: 'none', evidenceStatus: 'host_context_bound' } },
552
+ { candidateId: 'c3', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'input_evidence_empty', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
553
+ { candidateId: 'c4', recommendationKind: 'prompt', admission: { decision: 'needs_evidence', reason: 'confidence_below_threshold:0.45<0.50', nextAction: 'add evidence', evidenceStatus: 'host_context_bound' } },
554
+ ],
555
+ } as PainToPrincipleOutput;
556
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
557
+ const exitSpy = mockProcessExit();
558
+
559
+ await handlePainRecord({ reason: 'test pain', json: true });
560
+
561
+ const allOutput = logSpy.mock.calls.map(c => c.join(' ')).join(' ');
562
+ // Explicit admission summary; must not read as completed internalization.
563
+ expect(allOutput).toMatch(/0 (?:of|\/) 4|admitted.*0|0.*admitted/i);
564
+ const jsonOutput = JSON.parse(logSpy.mock.calls.at(-1)![0] ?? '{}');
565
+ const jsonStr = JSON.stringify(jsonOutput);
566
+ expect(jsonStr).toMatch(/no candidate was admitted|admitted:\s*0/i);
399
567
 
400
568
  logSpy.mockRestore();
401
569
  exitSpy.mockRestore();