@principles/pd-cli 1.93.0 → 1.95.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.
@@ -4,8 +4,14 @@ import { RuntimeStateManager } from '@principles/core/runtime-v2';
4
4
  import { SqliteContextAssembler } from '@principles/core/runtime-v2';
5
5
  import { SqliteHistoryQuery } from '@principles/core/runtime-v2';
6
6
  import { StoreEventEmitter } from '@principles/core/runtime-v2';
7
- import { DiagnosticianRunner } from '@principles/core/runtime-v2';
8
- import { PassThroughValidator } from '@principles/core/runtime-v2';
7
+ import {
8
+ SplitDiagnosticianRunner,
9
+ DiagRootCauseRunner,
10
+ DiagDistillerRunner,
11
+ DiagRouterRunner,
12
+ DefaultDiagRootCauseValidator,
13
+ DefaultDiagDistillerValidator,
14
+ } from '@principles/core/runtime-v2';
9
15
  import { SqliteDiagnosticianCommitter } from '@principles/core/runtime-v2';
10
16
  import { TestDoubleRuntimeAdapter } from '@principles/core/runtime-v2';
11
17
  import { PainSignalBridge } from '@principles/core/runtime-v2';
@@ -72,20 +78,107 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
72
78
  const contextAssembler = new SqliteContextAssembler(taskStore, historyQuery, runStore);
73
79
  const eventEmitter = new StoreEventEmitter();
74
80
  const committer = new SqliteDiagnosticianCommitter(sqliteConn);
75
- const validator = new PassThroughValidator();
81
+ const runIdToTaskId = new Map<string, string>();
82
+ let runCounter = 0;
76
83
 
77
- const runtimeAdapter = new TestDoubleRuntimeAdapter(
78
- { onFetchOutput: () => ({ runId: `synth-${painId}`, payload: diagnosticianOutput }) },
79
- `diagnosis_${painId}`,
80
- );
84
+ // Single test-double adapter handles all pains with stage-aware output
85
+ const runtimeAdapter = new TestDoubleRuntimeAdapter({
86
+ onStartRun: (input) => {
87
+ runCounter += 1;
88
+ const runId = `td-${runCounter}`;
89
+ const taskId = input.taskRef?.taskId ?? '';
90
+ runIdToTaskId.set(runId, taskId);
91
+ return {
92
+ runId,
93
+ runtimeKind: 'test-double',
94
+ startedAt: new Date().toISOString(),
95
+ };
96
+ },
97
+ onFetchOutput: async (runId: string) => {
98
+ const taskId = runIdToTaskId.get(runId) ?? '';
99
+ if (taskId.includes('diag_rootcause')) {
100
+ return {
101
+ runId,
102
+ payload: {
103
+ valid: true,
104
+ diagnosisId: `diag-${runId}`,
105
+ taskId,
106
+ summary: 'Mock rootcause summary for synthetic-baseline',
107
+ causalChain: [{ why: 1, statement: 'why', evidenceRefs: ['ref-1'] }],
108
+ rootCause: 'Design: Mock rootcause',
109
+ rootCauseCategory: 'Design',
110
+ evidence: [{ sourceRef: 'ref-1', note: 'note' }],
111
+ confidence: 0.9,
112
+ },
113
+ };
114
+ } else if (taskId.includes('diag_distiller')) {
115
+ const parentTaskId = taskId.replace('diag_distiller-', '');
116
+ const stageATaskId = `diag_rootcause-${parentTaskId}`;
117
+ const artifacts = stateManager
118
+ ? await stateManager.piArtifactStore.listBySourceTaskId(stageATaskId)
119
+ : [];
120
+ const sourceRootCauseArtifactId = artifacts[0]?.artifactId ?? 'art-rc';
121
+ return {
122
+ runId,
123
+ payload: {
124
+ valid: true,
125
+ taskId,
126
+ sourceRootCauseArtifactId,
127
+ abstractedPrinciple: 'Mock abstracted principle',
128
+ rationale: 'Mock rationale',
129
+ groundedOnCorePrincipleIds: ['T-01'],
130
+ scope: 'domain',
131
+ confidence: 0.9,
132
+ },
133
+ };
134
+ } else {
135
+ return {
136
+ runId,
137
+ payload: diagnosticianOutput,
138
+ };
139
+ }
140
+ },
141
+ });
81
142
 
82
- const runner = new DiagnosticianRunner(
143
+ const rootCauseRunner = new DiagRootCauseRunner(
83
144
  {
84
145
  stateManager,
146
+ runtimeAdapter,
147
+ eventEmitter,
148
+ artifactStore: stateManager.piArtifactStore,
149
+ validator: new DefaultDiagRootCauseValidator(),
85
150
  contextAssembler,
151
+ },
152
+ {
153
+ owner: 'synthetic-baseline',
154
+ runtimeKind: 'test-double',
155
+ pollIntervalMs: 50,
156
+ timeoutMs: 10000,
157
+ },
158
+ );
159
+
160
+ const distillerRunner = new DiagDistillerRunner(
161
+ {
162
+ stateManager,
86
163
  runtimeAdapter,
87
164
  eventEmitter,
88
- validator,
165
+ artifactStore: stateManager.piArtifactStore,
166
+ validator: new DefaultDiagDistillerValidator(),
167
+ },
168
+ {
169
+ owner: 'synthetic-baseline',
170
+ runtimeKind: 'test-double',
171
+ pollIntervalMs: 50,
172
+ timeoutMs: 10000,
173
+ },
174
+ );
175
+
176
+ const routerRunner = new DiagRouterRunner(
177
+ {
178
+ stateManager,
179
+ runtimeAdapter,
180
+ eventEmitter,
181
+ artifactStore: stateManager.piArtifactStore,
89
182
  committer,
90
183
  },
91
184
  {
@@ -96,6 +189,15 @@ export async function runSyntheticBaseline(opts: SyntheticBaselineRunnerOptions)
96
189
  },
97
190
  );
98
191
 
192
+ const runner = new SplitDiagnosticianRunner({
193
+ rootCauseRunner,
194
+ distillerRunner,
195
+ routerRunner,
196
+ stateManager,
197
+ committer,
198
+ perStageTimeoutMs: 10000,
199
+ });
200
+
99
201
  const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir });
100
202
  const intakeService = new CandidateIntakeService({ stateManager, ledgerAdapter });
101
203
 
@@ -758,9 +758,18 @@ describe('CLI command wiring (pd console open)', () => {
758
758
 
759
759
  function runPd(args: string[], cwd: string): string {
760
760
  try {
761
+ const env: Record<string, string> = { ...process.env };
762
+ if (!args.includes('--workspace') && !args.includes('--help') && !args.includes('-h')) {
763
+ env.USERPROFILE = '/nonexistent';
764
+ env.HOME = '/nonexistent';
765
+ env.HOMEPATH = '/nonexistent';
766
+ env.HOMEDRIVE = '/nonexistent';
767
+ delete env.PD_WORKSPACE_DIR;
768
+ }
761
769
  return execFileSync('node', [getBuiltPdCliPath(), ...args], {
762
770
  encoding: 'utf8',
763
771
  cwd,
772
+ env,
764
773
  });
765
774
  } catch (err: unknown) {
766
775
  if (err && typeof err === 'object' && Object.hasOwn(err, 'stdout')) {
@@ -57,11 +57,17 @@ vi.mock('@principles/core/runtime-v2', () => {
57
57
  SqliteSourceTraceLocator: vi.fn().mockImplementation(function () { return {}; }),
58
58
  StoreEventEmitter: vi.fn().mockImplementation(function () { return {}; }),
59
59
  storeEmitter: { emitTelemetry: vi.fn() },
60
- DiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
61
- PassThroughValidator: vi.fn().mockImplementation(function () { return {}; }),
62
- DefaultDiagnosticianValidator: vi.fn().mockImplementation(function () { return {}; }),
60
+ SplitDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
61
+ DiagRootCauseRunner: vi.fn().mockImplementation(function () { return {}; }),
62
+ DiagDistillerRunner: vi.fn().mockImplementation(function () { return {}; }),
63
+ DiagRouterRunner: vi.fn().mockImplementation(function () { return {}; }),
64
+ DefaultDiagRootCauseValidator: vi.fn().mockImplementation(function () { return {}; }),
65
+ DefaultDiagDistillerValidator: vi.fn().mockImplementation(function () { return {}; }),
66
+ DisabledDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
63
67
  TestDoubleRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
64
68
  OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
69
+ PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
70
+ SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
65
71
  PDRuntimeError: class PDRuntimeError extends Error {
66
72
  constructor(public category: string, message: string) {
67
73
  super(message);
@@ -78,8 +84,12 @@ vi.mock('@principles/core/runtime-v2', () => {
78
84
  agentId: 'main',
79
85
  }),
80
86
  isRuntimeConfigError: vi.fn().mockReturnValue(false),
81
- isFeatureEnabled: vi.fn().mockReturnValue(false),
87
+ isFeatureEnabled: vi.fn().mockReturnValue(true),
82
88
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
89
+ validatePdConfig: vi.fn().mockReturnValue({ valid: true, errors: [] }),
90
+ computeEffectivePdConfig: vi.fn().mockReturnValue({ config: {}, source: 'defaults', warnings: [] }),
91
+ computeFeatureFlagsFromConfig: vi.fn().mockReturnValue({}),
92
+ redactPdConfig: vi.fn().mockImplementation((c) => c),
83
93
  run: vi.fn().mockResolvedValue({
84
94
  status: 'succeeded',
85
95
  taskId: 'test-task-1',
@@ -155,7 +155,7 @@ describe('pd pain record async mode (PRI-369)', () => {
155
155
  const allOutput = logSpy.mock.calls.map(c => c.join(' ')).join(' ');
156
156
  expect(allOutput).toContain('[SUBMITTED]');
157
157
  expect(allOutput).toContain('submitted');
158
- expect(allOutput).toContain('pd task show');
158
+ expect(allOutput).toContain('Next action: pd diagnose run --task-id');
159
159
  expect(exitSpy).not.toHaveBeenCalledWith(1);
160
160
 
161
161
  logSpy.mockRestore();
@@ -240,4 +240,79 @@ describe('pd pain record async mode (PRI-369)', () => {
240
240
  logSpy.mockRestore();
241
241
  exitSpy.mockRestore();
242
242
  });
243
+
244
+ // 7. Stubbed process.exit(1) does not cause side effects in JSON mode
245
+ it('stubbed process.exit(1) in JSON mode does not execute else branch', async () => {
246
+ mockRecordPainResult = {
247
+ status: 'failed',
248
+ painId: 'manual_123_abc',
249
+ taskId: 'diagnosis_manual_123_abc',
250
+ candidateIds: ['c1'],
251
+ ledgerEntryIds: ['l1'],
252
+ observabilityWarnings: [],
253
+ failureCategory: 'runtime_unavailable' as FailureCategory,
254
+ latencyMs: 5,
255
+ message: 'Task creation failed',
256
+ };
257
+
258
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
259
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
260
+ const exitSpy = mockProcessExit();
261
+
262
+ await handlePainRecord({ reason: 'test pain', json: true });
263
+
264
+ // Verify JSON output was printed
265
+ expect(logSpy).toHaveBeenCalledTimes(1);
266
+ const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]);
267
+ expect(jsonOutput.status).toBe('failed');
268
+ expect(jsonOutput.candidateIds).toEqual(['c1']);
269
+
270
+ // Verify process.exit(1) was called
271
+ expect(exitSpy).toHaveBeenCalledWith(1);
272
+
273
+ // Verify else branch was NOT executed (no error.log for [FAIL] message)
274
+ expect(errorSpy).not.toHaveBeenCalled();
275
+
276
+ logSpy.mockRestore();
277
+ errorSpy.mockRestore();
278
+ exitSpy.mockRestore();
279
+ });
280
+
281
+ // 8. Stubbed process.exit(1) does not cause side effects in text mode
282
+ it('stubbed process.exit(1) in text mode stops after error message', async () => {
283
+ mockRecordPainResult = {
284
+ status: 'failed',
285
+ painId: 'manual_123_abc',
286
+ taskId: 'diagnosis_manual_123_abc',
287
+ candidateIds: ['c1'],
288
+ ledgerEntryIds: ['l1'],
289
+ observabilityWarnings: [],
290
+ failureCategory: 'runtime_unavailable' as FailureCategory,
291
+ latencyMs: 5,
292
+ message: 'Test failure message',
293
+ };
294
+
295
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
296
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
297
+ const exitSpy = mockProcessExit();
298
+
299
+ await handlePainRecord({ reason: 'test pain' }); // Not json mode
300
+
301
+ // Verify [FAIL] error message was printed
302
+ expect(errorSpy).toHaveBeenCalledWith('[FAIL] Pain signal failed:', 'Test failure message');
303
+
304
+ // Verify process.exit(1) was called
305
+ expect(exitSpy).toHaveBeenCalledWith(1);
306
+
307
+ // Verify no success/submitted/skip/retry messages were printed after exit
308
+ const allLogMessages = logSpy.mock.calls.flat();
309
+ const forbiddenPatterns = ['[OK]', '[SUBMITTED]', '[SKIP]', '[RETRY]'];
310
+ for (const pattern of forbiddenPatterns) {
311
+ expect(allLogMessages).not.toEqual(expect.stringContaining(pattern));
312
+ }
313
+
314
+ logSpy.mockRestore();
315
+ errorSpy.mockRestore();
316
+ exitSpy.mockRestore();
317
+ });
243
318
  });
@@ -89,11 +89,17 @@ vi.mock('@principles/core/runtime-v2', () => {
89
89
  SqliteSourceTraceLocator: vi.fn().mockImplementation(function () { return {}; }),
90
90
  StoreEventEmitter: vi.fn().mockImplementation(function () { return {}; }),
91
91
  storeEmitter: { emitTelemetry: vi.fn() },
92
- DiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
93
- DefaultDiagnosticianValidator: vi.fn().mockImplementation(function () { return {}; }),
92
+ SplitDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
93
+ DiagRootCauseRunner: vi.fn().mockImplementation(function () { return {}; }),
94
+ DiagDistillerRunner: vi.fn().mockImplementation(function () { return {}; }),
95
+ DiagRouterRunner: vi.fn().mockImplementation(function () { return {}; }),
96
+ DefaultDiagRootCauseValidator: vi.fn().mockImplementation(function () { return {}; }),
97
+ DefaultDiagDistillerValidator: vi.fn().mockImplementation(function () { return {}; }),
98
+ DisabledDiagnosticianRunner: vi.fn().mockImplementation(function () { return {}; }),
94
99
  TestDoubleRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
95
100
  OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
96
101
  PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
102
+ SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
97
103
  PDRuntimeError: class PDRuntimeError extends Error {
98
104
  constructor(public category: string, message: string) {
99
105
  super(message);
@@ -103,7 +109,12 @@ vi.mock('@principles/core/runtime-v2', () => {
103
109
  CandidateIntakeService: MockCandidateIntakeService,
104
110
  resolveRuntimeConfig: mockResolveRuntimeConfig,
105
111
  isRuntimeConfigError: vi.fn().mockReturnValue(false),
112
+ isFeatureEnabled: vi.fn().mockReturnValue(true),
106
113
  resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
114
+ validatePdConfig: vi.fn().mockReturnValue({ valid: true, errors: [] }),
115
+ computeEffectivePdConfig: vi.fn().mockReturnValue({ config: {}, source: 'defaults', warnings: [] }),
116
+ computeFeatureFlagsFromConfig: vi.fn().mockReturnValue({}),
117
+ redactPdConfig: vi.fn().mockImplementation((c) => c),
107
118
  run: mockRun,
108
119
  status: vi.fn(),
109
120
  };
@@ -149,12 +149,33 @@ describe('E2E: pd candidate intake flow', () => {
149
149
  }
150
150
  }
151
151
 
152
- function readLedgerFile(workspace: string): any[] {
153
- const ledgerPath = join(workspace, '.pd', 'principle-tree-ledger.json');
152
+ function readLedgerFile(workspace: string): Array<{ id: string; sourceRef: string; status: string; evaluability: string }> {
153
+ const ledgerPath = join(workspace, '.state', 'principle_training_state.json');
154
154
  if (!existsSync(ledgerPath)) return [];
155
155
  const content = readFileSync(ledgerPath, 'utf-8');
156
- const data = JSON.parse(content);
157
- return data.principles || [];
156
+ const data: unknown = JSON.parse(content);
157
+ if (typeof data !== 'object' || data === null) return [];
158
+ const tree = (data as Record<string, unknown>)._tree ?? (data as Record<string, unknown>).tree ?? {};
159
+ if (typeof tree !== 'object' || tree === null) return [];
160
+ const principles = (tree as Record<string, unknown>).principles ?? {};
161
+ if (typeof principles !== 'object' || principles === null) return [];
162
+ return Object.values(principles).map((p: unknown) => {
163
+ if (typeof p !== 'object' || p === null) {
164
+ return { id: '', sourceRef: 'candidate://', status: '', evaluability: '' };
165
+ }
166
+ const pObj = p as Record<string, unknown>;
167
+ const painIds = pObj.derivedFromPainIds;
168
+ const candidateId = Array.isArray(painIds) && typeof painIds[0] === 'string' ? painIds[0] : '';
169
+ const id = typeof pObj.id === 'string' ? pObj.id : '';
170
+ const rawStatus = typeof pObj.status === 'string' ? pObj.status : '';
171
+ const evaluability = typeof pObj.evaluability === 'string' ? pObj.evaluability : '';
172
+ return {
173
+ id,
174
+ sourceRef: `candidate://${candidateId}`,
175
+ status: rawStatus === 'candidate' ? 'probation' : rawStatus,
176
+ evaluability,
177
+ };
178
+ });
158
179
  }
159
180
 
160
181
  it('Test 1 (Happy path E2E): pending candidate → intake → consumed → ledgerEntryId in output', () => {