@principles/pd-cli 1.106.0 → 1.108.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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * pd mvp smoke — MVP mainline readiness check (PRI-397 / C5).
3
+ *
4
+ * Assembles a MainlineSnapshot from the shared reader, judges it via the
5
+ * pure assertMainlineContract, and outputs a single JSON verdict.
6
+ *
7
+ * Rules (EP-04, EP-02):
8
+ * - Read-only by default. NEVER mutates workspace state.
9
+ * - Uses the shared mainline-snapshot-assembler (no new chain logic).
10
+ * - --json mode: stdout = exactly one parseable JSON object — even on failure.
11
+ * Every degraded/refused path emits a structured `{ok, reason, nextAction}`
12
+ * JSON so CI/script consumers can branch on outcome (EP-04 Rule 6).
13
+ * - Human mode: formatted output to stdout.
14
+ * - Exit code: 0 on overall === 'ok', 1 on overall === 'violation' or failure.
15
+ */
16
+
17
+ import type { Command } from 'commander';
18
+ import {
19
+ assembleMainlineSnapshot,
20
+ assertMainlineContract,
21
+ } from '../services/mainline-snapshot-assembler.js';
22
+ import { resolveWorkspaceDir } from '../resolve-workspace.js';
23
+ import { withWorkspaceAndJson } from './command-helpers.js';
24
+
25
+ export interface MvpSmokeOptions {
26
+ workspace?: string;
27
+ json?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Classify a thrown error so the failure JSON can name a real next action.
32
+ * EP-03: never silently swallow; emit a structured reason + actionable next step.
33
+ */
34
+ function classifySmokeError(err: unknown): { reason: string; nextAction: string } {
35
+ if (err instanceof Error) {
36
+ const msg = err.message;
37
+ const lower = msg.toLowerCase();
38
+ // No .pd/state.db on disk (fresh / reset workspace) is the post-PRI-398
39
+ // expected first failure — name the recovery path explicitly.
40
+ if (
41
+ msg.includes('SQLITE_CANTOPEN') ||
42
+ lower.includes('no such file') ||
43
+ lower.includes('no such table') ||
44
+ lower.includes('cannot open database') ||
45
+ lower.includes('directory does not exist')
46
+ ) {
47
+ return {
48
+ reason: `Workspace database is missing or unreadable: ${msg}`,
49
+ nextAction: 'Run "pd runtime internalization integrity-repair --confirm" or restore the workspace; this command requires a bootstrapped .pd/state.db.',
50
+ };
51
+ }
52
+ if (lower.includes('workspace') && lower.includes('not configured')) {
53
+ return {
54
+ reason: msg,
55
+ nextAction: 'Set --workspace <path>, PD_WORKSPACE_DIR, or add workspace.default to .pd/config.yaml.',
56
+ };
57
+ }
58
+ return {
59
+ reason: msg,
60
+ nextAction: 'Inspect the workspace state and retry. Run "pd config doctor --json" to validate the workspace.',
61
+ };
62
+ }
63
+ return {
64
+ reason: `Unknown failure: ${String(err)}`,
65
+ nextAction: 'Inspect the workspace state and retry. Run "pd config doctor --json" to validate the workspace.',
66
+ };
67
+ }
68
+
69
+ export async function handleMvpSmoke(opts: MvpSmokeOptions): Promise<void> {
70
+ // Pass through resolveWorkspaceDir to honor the consistency warning for
71
+ // --workspace flags that disagree with the config default. The resolver
72
+ // returns an absolute, normalized path either way.
73
+ const workspaceDir = resolveWorkspaceDir(opts.workspace);
74
+
75
+ let result;
76
+ try {
77
+ result = await assembleMainlineSnapshot({ workspaceDir });
78
+ } catch (err: unknown) {
79
+ const { reason, nextAction } = classifySmokeError(err);
80
+ if (opts.json) {
81
+ // EP-04 Rule 1 + 6: stdout = exactly one parseable JSON object carrying
82
+ // a structured reason and nextAction, even on the failure path.
83
+ console.log(JSON.stringify({
84
+ ok: false,
85
+ reason,
86
+ nextAction,
87
+ workspace: workspaceDir,
88
+ }, null, 2));
89
+ } else {
90
+ console.error(`MVP smoke failed: ${reason}`);
91
+ console.error(`nextAction: ${nextAction}`);
92
+ }
93
+ process.exit(1);
94
+ return;
95
+ }
96
+
97
+ const verdict = assertMainlineContract(result.snapshot);
98
+
99
+ if (opts.json) {
100
+ console.log(JSON.stringify({
101
+ ok: verdict.overall === 'ok',
102
+ verdict,
103
+ warnings: result.warnings,
104
+ resolvedPainId: result.resolvedPainId,
105
+ }, null, 2));
106
+ } else {
107
+ console.log(`\nMVP Smoke — ${workspaceDir}\n`);
108
+ console.log(` Overall: ${verdict.overall}`);
109
+ console.log(` Pain ID: ${verdict.painId ?? '(none)'}`);
110
+ console.log(` Generated At: ${verdict.generatedAt}\n`);
111
+
112
+ console.log(' Stages:');
113
+ for (const s of verdict.stages) {
114
+ const icon = s.status === 'ok' ? ' OK' : s.status === 'violation' ? ' VIOLATION' : ' SKIP';
115
+ console.log(` [${icon}] ${s.stage}`);
116
+ console.log(` ${s.reason}`);
117
+ if (s.nextAction) {
118
+ console.log(` nextAction: ${s.nextAction}`);
119
+ }
120
+ console.log('');
121
+ }
122
+
123
+ if (result.warnings.length > 0) {
124
+ console.log(' Warnings:');
125
+ for (const w of result.warnings) {
126
+ console.log(` - ${w}`);
127
+ }
128
+ console.log('');
129
+ }
130
+ }
131
+
132
+ if (verdict.overall === 'violation') {
133
+ process.exit(1);
134
+ return;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Register the `pd mvp` parent command and its `smoke` subcommand.
140
+ *
141
+ * Single source of truth for both production (`index.ts`) and parser tests
142
+ * (`mvp-smoke.test.ts`). If a test passes against this function, the same
143
+ * registration runs in production — flag typos surface at parseAsync time.
144
+ */
145
+ export function registerMvpCommands(program: Command): Command {
146
+ const mvpCmd = program
147
+ .command('mvp')
148
+ .description('MVP readiness commands');
149
+
150
+ withWorkspaceAndJson(
151
+ mvpCmd
152
+ .command('smoke')
153
+ .description('Check MVP mainline readiness: assemble snapshot → assert contract → structured verdict')
154
+ .action(async (opts: { workspace?: string; json?: boolean }) => {
155
+ await handleMvpSmoke({ workspace: opts.workspace, json: opts.json });
156
+ }),
157
+ );
158
+
159
+ return mvpCmd;
160
+ }
@@ -6,29 +6,55 @@
6
6
  * pd task show <taskId>
7
7
  */
8
8
  import * as path from 'path';
9
+ import type { Command } from 'commander';
9
10
  import { RuntimeStateManager, MalformedRunError, type RunRecord } from '@principles/core';
10
11
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
12
+ import { withWorkspaceAndJson } from './command-helpers.js';
11
13
 
12
14
  interface TaskListOptions {
13
15
  status?: string;
14
16
  kind?: string;
15
17
  limit?: number;
18
+ workspace?: string;
19
+ json?: boolean;
16
20
  }
17
21
 
18
22
  export async function handleTaskList(opts: TaskListOptions): Promise<void> {
19
- const workspaceDir = resolveWorkspaceDir();
23
+ // Pass through resolveWorkspaceDir to honor its consistency warning when
24
+ // --workspace disagrees with workspace.default in .pd/config.yaml.
25
+ const workspaceDir = resolveWorkspaceDir(opts.workspace);
20
26
  const stateManager = new RuntimeStateManager({ workspaceDir });
21
- await stateManager.initialize();
22
27
 
23
28
  try {
29
+ await stateManager.initialize();
30
+
24
31
  const filter: Record<string, string | number> = {};
25
32
  if (opts.status) filter.status = opts.status;
26
33
  if (opts.kind) filter.taskKind = opts.kind;
27
34
  if (opts.limit) filter.limit = opts.limit;
28
35
 
29
-
30
36
  const tasks = await stateManager.listTasks(Object.keys(filter).length > 0 ? filter : undefined);
31
37
 
38
+ if (opts.json) {
39
+ console.log(JSON.stringify({
40
+ ok: true,
41
+ count: tasks.length,
42
+ workspace: workspaceDir,
43
+ tasks: tasks.map((t) => ({
44
+ taskId: t.taskId,
45
+ taskKind: t.taskKind,
46
+ status: t.status,
47
+ attemptCount: t.attemptCount,
48
+ maxAttempts: t.maxAttempts,
49
+ leaseOwner: t.leaseOwner ?? null,
50
+ leaseExpiresAt: t.leaseExpiresAt ?? null,
51
+ createdAt: t.createdAt,
52
+ updatedAt: t.updatedAt,
53
+ })),
54
+ }, null, 2));
55
+ return;
56
+ }
57
+
32
58
  if (tasks.length === 0) {
33
59
  console.log('No tasks found.');
34
60
  return;
@@ -57,6 +83,23 @@ export async function handleTaskList(opts: TaskListOptions): Promise<void> {
57
83
  );
58
84
  }
59
85
  console.log('');
86
+ } catch (err: unknown) {
87
+ // EP-04: failure paths emit a single parseable JSON object with
88
+ // structured reason + nextAction (mirror handleTaskShow).
89
+ if (opts.json) {
90
+ const reason = err instanceof Error ? err.message : String(err);
91
+ console.log(JSON.stringify({
92
+ ok: false,
93
+ count: 0,
94
+ workspace: workspaceDir,
95
+ reason,
96
+ nextAction: 'Check workspace path and database accessibility. The workspace may need bootstrap (run "pd runtime internalization integrity-repair --confirm") or the workspace dir may be wrong.',
97
+ }, null, 2));
98
+ } else {
99
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
100
+ }
101
+ process.exit(1);
102
+ return;
60
103
  } finally {
61
104
  await stateManager.close();
62
105
  }
@@ -204,3 +247,25 @@ export async function handleTaskShow(opts: TaskShowOptions): Promise<void> {
204
247
  await stateManager.close();
205
248
  }
206
249
  }
250
+
251
+ /**
252
+ * Register the `pd task list` subcommand.
253
+ *
254
+ * Single source of truth for both production (`index.ts`) and parser tests
255
+ * (mvp-smoke.test.ts). Reuses the `withWorkspaceAndJson` helper so adding a
256
+ * new --workspace/--json pair elsewhere is one line, not five.
257
+ */
258
+ export function registerTaskListCommand(taskCmd: Command): Command {
259
+ const listCmd = taskCmd
260
+ .command('list')
261
+ .description('List runtime tasks')
262
+ .option('-s, --status <status>', 'Filter by status (pending, leased, retry_wait, succeeded, failed)')
263
+ .option('-k, --kind <kind>', 'Filter by task kind')
264
+ .option('-l, --limit <number>', 'Limit number of results', parseInt, 50)
265
+ .action(async (opts: { status?: string; kind?: string; limit?: number; workspace?: string; json?: boolean }) => {
266
+ await handleTaskList({ status: opts.status, kind: opts.kind, limit: opts.limit, workspace: opts.workspace, json: opts.json });
267
+ });
268
+
269
+ withWorkspaceAndJson(listCmd);
270
+ return listCmd;
271
+ }
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ import { handleEvolutionTasksList } from './commands/evolution-tasks-list.js';
16
16
  import { handleEvolutionTasksShow } from './commands/evolution-tasks-show.js';
17
17
  import { handleHealth } from './commands/health.js';
18
18
  import { handleCentralSync } from './commands/central-sync.js';
19
- import { handleTaskList, handleTaskShow } from './commands/task.js';
19
+ import { handleTaskShow, registerTaskListCommand } from './commands/task.js';
20
20
  import { handleRunList, handleRunShow } from './commands/run.js';
21
21
  import { handleTrajectoryLocate } from './commands/trajectory.js';
22
22
  import { handleHistoryQuery } from './commands/history.js';
@@ -50,6 +50,7 @@ import { handleProvenChannelBaseline } from './commands/proven-channel-baseline.
50
50
  import { handleDemoStoryA } from './commands/demo-story-a.js';
51
51
  import { handleRuntimeFeaturesStatus } from './commands/runtime-features.js';
52
52
  import { handleConfigDoctor } from './commands/config-doctor.js';
53
+ import { registerMvpCommands } from './commands/mvp-smoke.js';
53
54
 
54
55
  import { createRequire } from 'module';
55
56
  const require = createRequire(import.meta.url);
@@ -191,15 +192,7 @@ const rtTaskCmd = program
191
192
  .command('task')
192
193
  .description('Runtime v2 task inspection');
193
194
 
194
- rtTaskCmd
195
- .command('list')
196
- .description('List runtime tasks')
197
- .option('-s, --status <status>', 'Filter by status (pending, leased, retry_wait, succeeded, failed)')
198
- .option('-k, --kind <kind>', 'Filter by task kind')
199
- .option('-l, --limit <number>', 'Limit number of results', parseInt, 50)
200
- .action(async (opts) => {
201
- await handleTaskList(opts);
202
- });
195
+ registerTaskListCommand(rtTaskCmd);
203
196
 
204
197
  rtTaskCmd
205
198
  .command('show <taskId>')
@@ -885,6 +878,10 @@ const _legacyCleanupCmd = legacyCmd
885
878
  await handleLegacyCleanup(opts.workspace, apply);
886
879
  });
887
880
 
881
+ // ─── MVP Smoke (PRI-397) ────────────────────────────────────────────────────
882
+
883
+ registerMvpCommands(program);
884
+
888
885
  const consoleCmd = program
889
886
  .command('console')
890
887
  .description('Start the pd-console web UI for principle review (default: legacy launcher)')
@@ -281,4 +281,157 @@ funnels:
281
281
  expect(typeof parsed).toBe('object');
282
282
  });
283
283
  });
284
+
285
+ // ── Boundary Condition Tests ────────────────────────────────────────────────
286
+
287
+ describe('resolveRuntimeWithOverrides', () => {
288
+ it('CLI overrides take precedence over config values', async () => {
289
+ writeConfigYaml(tmpDir, makeValidConfigYaml({ provider: 'lmstudio', model: 'local-model' }));
290
+
291
+ const { resolveRuntimeWithOverrides } = await import('../../src/services/resolve-runtime-from-pd-config.js');
292
+ const resolved = resolveRuntimeWithOverrides(tmpDir, {
293
+ provider: 'override-provider',
294
+ model: 'override-model',
295
+ }, () => 'test-key');
296
+
297
+ expect(resolved.mergedConfig).not.toBeNull();
298
+ if (resolved.mergedConfig) {
299
+ expect(resolved.mergedConfig.provider).toBe('override-provider');
300
+ expect(resolved.mergedConfig.model).toBe('override-model');
301
+ }
302
+ });
303
+
304
+ it('returns mergedConfig=null when base config is malformed', async () => {
305
+ const pdDir = path.join(tmpDir, '.pd');
306
+ fs.mkdirSync(pdDir, { recursive: true });
307
+ fs.writeFileSync(path.join(pdDir, 'config.yaml'), 'version: [invalid', 'utf8');
308
+
309
+ const { resolveRuntimeWithOverrides } = await import('../../src/services/resolve-runtime-from-pd-config.js');
310
+ const resolved = resolveRuntimeWithOverrides(tmpDir, {
311
+ provider: 'override',
312
+ }, () => 'test-key');
313
+
314
+ expect(resolved.mergedConfig).toBeNull();
315
+ });
316
+
317
+ it('preserves config values when overrides are undefined', async () => {
318
+ writeConfigYaml(tmpDir, makeValidConfigYaml({ provider: 'lmstudio', model: 'local-model' }));
319
+
320
+ const { resolveRuntimeWithOverrides } = await import('../../src/services/resolve-runtime-from-pd-config.js');
321
+ const resolved = resolveRuntimeWithOverrides(tmpDir, {
322
+ provider: undefined,
323
+ model: undefined,
324
+ }, () => 'test-key');
325
+
326
+ expect(resolved.mergedConfig).not.toBeNull();
327
+ if (resolved.mergedConfig) {
328
+ expect(resolved.mergedConfig.provider).toBe('lmstudio');
329
+ expect(resolved.mergedConfig.model).toBe('local-model');
330
+ }
331
+ });
332
+
333
+ it('merges numeric overrides correctly', async () => {
334
+ writeConfigYaml(tmpDir, makeValidConfigYaml());
335
+
336
+ const { resolveRuntimeWithOverrides } = await import('../../src/services/resolve-runtime-from-pd-config.js');
337
+ const resolved = resolveRuntimeWithOverrides(tmpDir, {
338
+ maxRetries: 5,
339
+ timeoutMs: 60000,
340
+ }, () => 'test-key');
341
+
342
+ expect(resolved.mergedConfig).not.toBeNull();
343
+ if (resolved.mergedConfig) {
344
+ expect(resolved.mergedConfig.maxRetries).toBe(5);
345
+ expect(resolved.mergedConfig.timeoutMs).toBe(60000);
346
+ }
347
+ });
348
+ });
349
+
350
+ describe('edge cases and error handling', () => {
351
+ it('handles empty workspace directory gracefully', async () => {
352
+ // No .pd directory at all
353
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
354
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
355
+
356
+ // Missing config should return defaults (ok=true with source='defaults')
357
+ expect(resolved.configLoadResult.ok).toBe(true);
358
+ expect(resolved.configSource).toBe('.pd/config.yaml');
359
+ });
360
+
361
+ it('handles config with missing runtimeProfiles section', async () => {
362
+ writeConfigYaml(tmpDir, {
363
+ version: 1,
364
+ features: {},
365
+ // Missing runtimeProfiles
366
+ });
367
+
368
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
369
+ const { isRuntimeConfigError } = await import('@principles/core/runtime-v2');
370
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
371
+
372
+ // Missing runtimeProfiles should either load with defaults or produce
373
+ // a RuntimeConfigError — never silently succeed with wrong config.
374
+ if (isRuntimeConfigError(resolved.result)) {
375
+ expect(resolved.result.reason).toBeTruthy();
376
+ expect(resolved.result.nextAction).toBeTruthy();
377
+ } else {
378
+ // If defaults were returned, configLoadResult.ok should be true
379
+ expect(resolved.configLoadResult.ok).toBe(true);
380
+ }
381
+ });
382
+
383
+ it('handles config with invalid runtime profile type', async () => {
384
+ writeConfigYaml(tmpDir, {
385
+ version: 1,
386
+ runtimeProfiles: {
387
+ 'bad-profile': {
388
+ type: 'invalid-type', // Invalid type
389
+ },
390
+ },
391
+ internalAgents: {
392
+ defaultRuntime: 'bad-profile',
393
+ agents: { diagnostician: { enabled: true, runtimeProfile: 'bad-profile' } },
394
+ },
395
+ });
396
+
397
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
398
+ const { isRuntimeConfigError } = await import('@principles/core/runtime-v2');
399
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
400
+
401
+ // Invalid type should be caught — verify external contract, not internal state
402
+ expect(isRuntimeConfigError(resolved.result)).toBe(true);
403
+ if (isRuntimeConfigError(resolved.result)) {
404
+ expect(resolved.result.reason).toBeTruthy();
405
+ expect(resolved.result.nextAction).toBeTruthy();
406
+ }
407
+ });
408
+
409
+ it('handles env var returning undefined for apiKeyEnv', async () => {
410
+ writeConfigYaml(tmpDir, makeValidConfigYaml());
411
+
412
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
413
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => undefined);
414
+
415
+ // Should handle undefined env var gracefully
416
+ expect(resolved.result).toBeDefined();
417
+ });
418
+
419
+ it('handles multiple legacy files detected', async () => {
420
+ writeConfigYaml(tmpDir, makeValidConfigYaml());
421
+
422
+ // Create multiple legacy files
423
+ const stateDir = path.join(tmpDir, '.state');
424
+ fs.mkdirSync(stateDir, { recursive: true });
425
+ fs.writeFileSync(path.join(stateDir, 'workflows.yaml'), 'version: 1', 'utf8');
426
+
427
+ const ffDir = path.join(tmpDir, '.pd');
428
+ fs.writeFileSync(path.join(ffDir, 'feature-flags.yaml'), 'flags: []', 'utf8');
429
+
430
+ const { resolveRuntimeFromPdConfig } = await import('../../src/services/resolve-runtime-from-pd-config.js');
431
+ const resolved = resolveRuntimeFromPdConfig(tmpDir, () => 'test-key');
432
+
433
+ expect(resolved.legacyWarnings.length).toBeGreaterThan(0);
434
+ expect(resolved.configLoadResult.legacyFilesDetected.length).toBeGreaterThanOrEqual(2);
435
+ });
436
+ });
284
437
  });