@principles/pd-cli 1.75.0 → 1.77.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.
@@ -1,10 +1,16 @@
1
1
  /**
2
- * PD Config Doctordiscovers and explains PD / OpenClaw configuration state.
2
+ * pd config doctorDiscover and explain PD / OpenClaw configuration state.
3
+ *
4
+ * PRI-305: Cutover to .pd/config.yaml.
5
+ * - Feature flags and internal agent runtime bindings come from .pd/config.yaml
6
+ * - .pd/feature-flags.yaml and .state/workflows.yaml are no longer production inputs
7
+ * - Legacy files are detected and reported as warnings
3
8
  *
4
9
  * PRI-299 MVP UX: provides a single, read-only view of:
5
10
  * - PD workspace config paths (with existence checks)
6
11
  * - OpenClaw config paths (with existence checks)
7
- * - Effective feature flags
12
+ * - Effective feature flags from .pd/config.yaml
13
+ * - Internal agent runtime binding readiness from .pd/config.yaml
8
14
  * - Provider/model/auth status (classified)
9
15
  *
10
16
  * Constraints:
@@ -16,11 +22,19 @@
16
22
 
17
23
  import * as fs from 'fs';
18
24
  import * as path from 'path';
19
- import yaml from 'js-yaml';
20
25
  import Database from 'better-sqlite3';
21
26
  import type { Database as BetterSqliteDatabase } from 'better-sqlite3';
22
- import { loadEffectiveFeatureFlags } from './feature-flag-loader.js';
23
- import { FEATURE_FLAGS_CONFIG_FILENAME, FEATURE_FLAGS_CONFIG_DIR } from './feature-flag-loader.js';
27
+ import {
28
+ loadPdConfig,
29
+ computeFlagsFromLoadResult,
30
+ redactLoadResult,
31
+ getPdConfigPath,
32
+ } from './pd-config-loader.js';
33
+ import { MVP_CHANNEL_IDS } from '@principles/core/runtime-v2';
34
+ import type {
35
+ RedactedAgentSummary,
36
+ RedactedRuntimeProfileSummary,
37
+ } from '@principles/core/runtime-v2';
24
38
 
25
39
  // ─── Public types ────────────────────────────────────────────────────────────
26
40
 
@@ -28,6 +42,7 @@ export type DoctorClassification =
28
42
  | 'healthy'
29
43
  | 'config_missing'
30
44
  | 'auth_missing'
45
+ | 'needs_probe'
31
46
  | 'rate_limit'
32
47
  | 'unavailable'
33
48
  | 'parse_failure'
@@ -38,7 +53,6 @@ export type DoctorStatus = 'ok' | 'degraded' | 'failed';
38
53
  export interface ConfigPathEntry {
39
54
  path: string;
40
55
  exists: boolean;
41
- /** Optional structural check (file is parseable JSON/YAML, etc.) */
42
56
  parseable?: boolean;
43
57
  }
44
58
 
@@ -46,12 +60,10 @@ export interface ProviderHealthEntry {
46
60
  provider: string | null;
47
61
  model: string | null;
48
62
  apiKeyEnv: string | null;
49
- /** True if the env var name is non-empty and the env var is set. */
50
63
  apiKeyPresent: boolean;
51
64
  classification: DoctorClassification;
52
65
  reason: string;
53
66
  nextAction: string;
54
- /** Source of the discovered config (e.g., 'workflows.yaml', 'cli_flag', 'default'). */
55
67
  source: string;
56
68
  }
57
69
 
@@ -63,14 +75,27 @@ export interface FeatureFlagSummary {
63
75
  warnings: string[];
64
76
  }
65
77
 
78
+ export interface InternalAgentDiagnostics {
79
+ name: string;
80
+ enabled: boolean;
81
+ runtimeProfileId: string;
82
+ runtimeProfileLabel: string;
83
+ readiness: 'ready' | 'not_ready' | 'needs_setup' | 'disabled' | 'unknown';
84
+ provider: string | null;
85
+ model: string | null;
86
+ apiKeyEnv: string | null;
87
+ apiKeyPresent: boolean;
88
+ reason: string;
89
+ nextAction: string;
90
+ }
91
+
66
92
  export interface DoctorOutput {
67
93
  status: DoctorStatus;
68
94
  workspaceDir: string;
69
95
  pdConfigPaths: {
70
96
  workspaceDir: ConfigPathEntry;
71
97
  pdDir: ConfigPathEntry;
72
- featureFlags: ConfigPathEntry;
73
- workflowsYaml: ConfigPathEntry;
98
+ configYaml: ConfigPathEntry;
74
99
  stateDb: ConfigPathEntry;
75
100
  };
76
101
  openclawConfigPaths: {
@@ -78,36 +103,17 @@ export interface DoctorOutput {
78
103
  openclawConfig: ConfigPathEntry;
79
104
  };
80
105
  featureFlags: FeatureFlagSummary;
106
+ internalAgents: InternalAgentDiagnostics[];
81
107
  providerHealth: ProviderHealthEntry[];
82
- internalAgents: {
83
- correctionObserver: {
84
- enabled: boolean;
85
- flagSource: string;
86
- status: 'disabled' | 'configured' | 'auth_missing' | 'config_missing' | 'unavailable';
87
- configSource: 'workflows.yaml' | 'env' | 'missing' | 'unavailable';
88
- provider: string | null;
89
- model: string | null;
90
- apiKeyEnv: string | null;
91
- apiKeyPresent: boolean;
92
- reason: string;
93
- nextAction: string;
94
- };
95
- };
96
108
  warnings: string[];
97
109
  reason?: string;
98
110
  nextActions: string[];
111
+ /** Legacy files detected (informational only, not used for resolution) */
112
+ legacyFilesDetected: string[];
99
113
  }
100
114
 
101
115
  // ─── Helpers ────────────────────────────────────────────────────────────────
102
116
 
103
- function isRecord(value: unknown): value is Record<string, unknown> {
104
- return value !== null && typeof value === 'object' && !Array.isArray(value);
105
- }
106
-
107
- /**
108
- * Resolve the OpenClaw home directory used to look up `openclaw.json` and
109
- * extension state. PD does not own this path — it only reports its existence.
110
- */
111
117
  export function getOpenClawHome(): string {
112
118
  const home = process.env.HOME
113
119
  || process.env.USERPROFILE
@@ -120,9 +126,6 @@ export function getOpenClawConfigPath(): string {
120
126
  return path.join(getOpenClawHome(), 'openclaw.json');
121
127
  }
122
128
 
123
- /**
124
- * Build a `ConfigPathEntry` with existence check.
125
- */
126
129
  function pathEntry(p: string): ConfigPathEntry {
127
130
  return { path: p, exists: fs.existsSync(p) };
128
131
  }
@@ -145,10 +148,10 @@ function classifyText(text: string): DoctorClassification | null {
145
148
  return null;
146
149
  }
147
150
 
151
+ // ─── State DB Inspection (unchanged from PRI-299) ────────────────────────────
152
+
148
153
  export interface InspectStateDbOptions {
149
- /** Max rows to scan from tasks table. */
150
154
  maxRows?: number;
151
- /** Max age in ms — only signals within this window are considered "recent". */
152
155
  maxAgeMs?: number;
153
156
  }
154
157
 
@@ -160,23 +163,13 @@ export interface RecentProviderSignal {
160
163
 
161
164
  export interface StateDbSignalResult {
162
165
  signal: RecentProviderSignal | null;
163
- /** True if the DB was found and could be opened in readonly mode. */
164
166
  dbReachable: boolean;
165
- /** Optional warning when the DB exists but couldn't be read. */
166
167
  warning?: string;
167
168
  }
168
169
 
169
170
  const DEFAULT_MAX_ROWS = 50;
170
- const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
171
+ const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
171
172
 
172
- /**
173
- * Read the state.db recent provider error signal (best-effort, structural only).
174
- *
175
- * We inspect recent task/run rows in `<workspaceDir>/.pd/state.db` to detect
176
- * `rate_limit` / `auth_missing` / `unavailable` signals from the live pipeline.
177
- * This is a bounded best-effort probe — if the DB is missing or unreadable we
178
- * return `null` and let the doctor fall back to config-only classification.
179
- */
180
173
  export async function inspectStateDbForProviderSignal(
181
174
  stateDbPath: string,
182
175
  nowMs: number = Date.now(),
@@ -201,13 +194,11 @@ export async function inspectStateDbForProviderSignal(
201
194
  }
202
195
 
203
196
  try {
204
- // Check for tasks table — drift-safe (ERR-026): assert via pragma, not assumption.
205
197
  const tableInfo = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'").get() as { name: string } | undefined;
206
198
  if (!tableInfo) {
207
199
  return { signal: null, dbReachable: true, warning: 'state.db has no tasks table — pipeline not initialized yet' };
208
200
  }
209
201
 
210
- // Find columns defensively.
211
202
  const taskCols = db.prepare("PRAGMA table_info(tasks)").all() as { name: string }[];
212
203
  const taskColNames = new Set(taskCols.map((c) => c.name));
213
204
  const hasErrorMessage = taskColNames.has('error_message');
@@ -222,7 +213,6 @@ export async function inspectStateDbForProviderSignal(
222
213
  return { signal: null, dbReachable: true, warning: 'state.db tasks table has no error_message column — provider signal unavailable' };
223
214
  }
224
215
 
225
- // Build a bounded scan: read up to maxRows recent failed tasks.
226
216
  const errorCol = hasErrorMessage ? 'error_message' : 'last_error_message';
227
217
  const tsCol = hasUpdatedAt ? 'updated_at' : hasLastErrorAt ? 'last_error_at' : hasCreatedAt ? 'created_at' : null;
228
218
  const tsExpr = tsCol ? `, ${tsCol} AS ts` : '';
@@ -243,7 +233,6 @@ export async function inspectStateDbForProviderSignal(
243
233
  }
244
234
  }
245
235
 
246
- // Also check for explicit error_category/failure_category columns if present.
247
236
  if (hasErrorCategory || hasFailureCategory) {
248
237
  const catCol = hasErrorCategory ? 'error_category' : 'failure_category';
249
238
  const catRows = db.prepare(
@@ -280,144 +269,127 @@ export async function inspectStateDbForProviderSignal(
280
269
  }
281
270
  }
282
271
 
283
- /**
284
- * Resolve the provider/model/apiKeyEnv from the workspace's `workflows.yaml`
285
- * funnel policy. Returns `null` for each field when not configured.
286
- *
287
- * NEVER leaks api key values or env var values.
288
- */
289
- export interface ProviderConfigFromWorkflows {
290
- provider: string | null;
291
- model: string | null;
292
- apiKeyEnv: string | null;
293
- baseUrl: string | null;
294
- source: 'workflows.yaml' | 'cli_flag' | 'default' | 'missing';
295
- /** True if the workflows.yaml file was found and parseable. */
296
- workflowsFound: boolean;
297
- /** Parse warning, if any. */
298
- parseWarning?: string;
299
- }
300
-
301
- const DIAGNOSTIC_FUNNEL_ID = 'pd-runtime-v2-diagnosis';
272
+ // ─── Internal Agent Diagnostics from .pd/config.yaml ─────────────────────────
302
273
 
303
- export async function resolveProviderConfigFromWorkflows(
304
- stateDir: string,
305
- opts: { cliProvider?: string; cliModel?: string; cliApiKeyEnv?: string; cliBaseUrl?: string } = {},
306
- ): Promise<ProviderConfigFromWorkflows> {
307
- const workflowsPath = path.join(stateDir, 'workflows.yaml');
308
- if (!fs.existsSync(workflowsPath)) {
309
- // Fall back to CLI flag values if provided.
310
- if (opts.cliProvider || opts.cliModel || opts.cliApiKeyEnv) {
311
- return {
312
- provider: opts.cliProvider ?? null,
313
- model: opts.cliModel ?? null,
314
- apiKeyEnv: opts.cliApiKeyEnv ?? null,
315
- baseUrl: opts.cliBaseUrl ?? null,
316
- source: 'cli_flag',
317
- workflowsFound: false,
318
- };
319
- }
274
+ function diagnoseInternalAgent(
275
+ agent: RedactedAgentSummary,
276
+ profile: RedactedRuntimeProfileSummary | undefined,
277
+ ): InternalAgentDiagnostics {
278
+ if (!agent.enabled) {
320
279
  return {
280
+ name: agent.name,
281
+ enabled: false,
282
+ runtimeProfileId: agent.runtimeProfileId,
283
+ runtimeProfileLabel: agent.runtimeProfileLabel,
284
+ readiness: 'disabled',
321
285
  provider: null,
322
286
  model: null,
323
287
  apiKeyEnv: null,
324
- baseUrl: null,
325
- source: 'missing',
326
- workflowsFound: false,
288
+ apiKeyPresent: false,
289
+ reason: `${agent.name} is disabled in .pd/config.yaml`,
290
+ nextAction: `Set internalAgents.agents.${agent.name}.enabled=true in .pd/config.yaml to enable`,
327
291
  };
328
292
  }
329
293
 
330
- let raw: string;
331
- try {
332
- raw = fs.readFileSync(workflowsPath, 'utf8');
333
- } catch (err) {
294
+ // Agent is enabled — check profile readiness
295
+ if (!profile) {
334
296
  return {
297
+ name: agent.name,
298
+ enabled: true,
299
+ runtimeProfileId: agent.runtimeProfileId,
300
+ runtimeProfileLabel: agent.runtimeProfileLabel,
301
+ readiness: 'needs_setup',
335
302
  provider: null,
336
303
  model: null,
337
304
  apiKeyEnv: null,
338
- baseUrl: null,
339
- source: 'workflows.yaml',
340
- workflowsFound: true,
341
- parseWarning: `workflows.yaml read failed: ${err instanceof Error ? err.message : String(err)}`,
305
+ apiKeyPresent: false,
306
+ reason: `Runtime profile '${agent.runtimeProfileId}' not found in .pd/config.yaml`,
307
+ nextAction: `Add runtime profile '${agent.runtimeProfileId}' to .pd/config.yaml runtimeProfiles, or change the agent's runtimeProfile reference`,
342
308
  };
343
309
  }
344
310
 
345
- let parsed: unknown;
346
- try {
347
- parsed = yaml.load(raw);
348
- } catch (err) {
349
- return {
350
- provider: null,
351
- model: null,
352
- apiKeyEnv: null,
353
- baseUrl: null,
354
- source: 'workflows.yaml',
355
- workflowsFound: true,
356
- parseWarning: `workflows.yaml parse error: ${err instanceof Error ? err.message : String(err)}`,
357
- };
358
- }
311
+ // Check apiKeyEnv for pi-ai profiles
312
+ if (profile.type === 'pi-ai') {
313
+ const apiKeyEnv = profile.apiKeyEnv ?? null;
314
+ const apiKeyPresent = !!apiKeyEnv && Object.prototype.hasOwnProperty.call(process.env, apiKeyEnv) && !!process.env[apiKeyEnv];
359
315
 
360
- if (!isRecord(parsed)) {
361
- return {
362
- provider: null,
363
- model: null,
364
- apiKeyEnv: null,
365
- baseUrl: null,
366
- source: 'workflows.yaml',
367
- workflowsFound: true,
368
- parseWarning: 'workflows.yaml root is not an object',
369
- };
370
- }
316
+ if (!apiKeyEnv) {
317
+ return {
318
+ name: agent.name,
319
+ enabled: true,
320
+ runtimeProfileId: agent.runtimeProfileId,
321
+ runtimeProfileLabel: agent.runtimeProfileLabel,
322
+ readiness: 'needs_setup',
323
+ provider: null,
324
+ model: null,
325
+ apiKeyEnv: null,
326
+ apiKeyPresent: false,
327
+ reason: `pi-ai profile '${profile.id}' missing apiKeyEnv`,
328
+ nextAction: `Add apiKeyEnv to runtime profile '${profile.id}' in .pd/config.yaml`,
329
+ };
330
+ }
371
331
 
372
- const funnelsRaw = parsed.funnels;
373
- if (!Array.isArray(funnelsRaw)) {
332
+ if (!apiKeyPresent) {
333
+ return {
334
+ name: agent.name,
335
+ enabled: true,
336
+ runtimeProfileId: agent.runtimeProfileId,
337
+ runtimeProfileLabel: agent.runtimeProfileLabel,
338
+ readiness: 'needs_setup',
339
+ provider: null,
340
+ model: null,
341
+ apiKeyEnv,
342
+ apiKeyPresent: false,
343
+ reason: `Environment variable '${apiKeyEnv}' is not set or empty`,
344
+ nextAction: `Set the environment variable '${apiKeyEnv}' with a valid API key`,
345
+ };
346
+ }
347
+
348
+ // pi-ai with key present — check state.db for signals
374
349
  return {
350
+ name: agent.name,
351
+ enabled: true,
352
+ runtimeProfileId: agent.runtimeProfileId,
353
+ runtimeProfileLabel: agent.runtimeProfileLabel,
354
+ readiness: 'not_ready', // runtime availability unknown without actual probe
375
355
  provider: null,
376
356
  model: null,
377
- apiKeyEnv: null,
378
- baseUrl: null,
379
- source: 'workflows.yaml',
380
- workflowsFound: true,
381
- parseWarning: 'workflows.yaml: funnels is not an array',
357
+ apiKeyEnv,
358
+ apiKeyPresent: true,
359
+ reason: `pi-ai profile configured with apiKeyEnv='${apiKeyEnv}' (key present); runtime availability unknown`,
360
+ nextAction: 'Run pd runtime probe to verify end-to-end connectivity',
382
361
  };
383
362
  }
384
363
 
385
- let policy: Record<string, unknown> | null = null;
386
- for (const f of funnelsRaw) {
387
- if (!isRecord(f)) continue;
388
- if (f.workflowId === DIAGNOSTIC_FUNNEL_ID && isRecord(f.policy)) {
389
- const { policy: candidate } = f;
390
- if (isRecord(candidate)) {
391
- policy = candidate;
392
- break;
393
- }
394
- }
395
- }
396
-
397
- if (policy === null) {
364
+ // OpenClaw profile
365
+ if (profile.readiness === 'needs_setup') {
398
366
  return {
367
+ name: agent.name,
368
+ enabled: true,
369
+ runtimeProfileId: agent.runtimeProfileId,
370
+ runtimeProfileLabel: agent.runtimeProfileLabel,
371
+ readiness: 'needs_setup',
399
372
  provider: null,
400
373
  model: null,
401
374
  apiKeyEnv: null,
402
- baseUrl: null,
403
- source: 'workflows.yaml',
404
- workflowsFound: true,
405
- parseWarning: `workflows.yaml: funnel '${DIAGNOSTIC_FUNNEL_ID}' not found`,
375
+ apiKeyPresent: false,
376
+ reason: `OpenClaw profile '${profile.id}' needs setup (missing provider/model)`,
377
+ nextAction: `Configure provider and model in runtime profile '${profile.id}' in .pd/config.yaml`,
406
378
  };
407
379
  }
408
380
 
409
- const provider = typeof policy.provider === 'string' ? policy.provider : null;
410
- const model = typeof policy.model === 'string' ? policy.model : null;
411
- const apiKeyEnv = typeof policy.apiKeyEnv === 'string' ? policy.apiKeyEnv : null;
412
- const baseUrl = typeof policy.baseUrl === 'string' ? policy.baseUrl : null;
413
-
414
381
  return {
415
- provider: provider || opts.cliProvider || null,
416
- model: model || opts.cliModel || null,
417
- apiKeyEnv: apiKeyEnv || opts.cliApiKeyEnv || null,
418
- baseUrl: baseUrl || opts.cliBaseUrl || null,
419
- source: 'workflows.yaml',
420
- workflowsFound: true,
382
+ name: agent.name,
383
+ enabled: true,
384
+ runtimeProfileId: agent.runtimeProfileId,
385
+ runtimeProfileLabel: agent.runtimeProfileLabel,
386
+ readiness: 'ready',
387
+ provider: null,
388
+ model: null,
389
+ apiKeyEnv: null,
390
+ apiKeyPresent: false,
391
+ reason: `OpenClaw profile '${profile.id}' is configured and ready`,
392
+ nextAction: 'No action required',
421
393
  };
422
394
  }
423
395
 
@@ -425,291 +397,149 @@ export async function resolveProviderConfigFromWorkflows(
425
397
 
426
398
  export interface BuildDoctorInput {
427
399
  workspaceDir: string;
428
- /** Optional CLI overrides for provider/model/apiKeyEnv. */
429
- cliProvider?: string;
430
- cliModel?: string;
431
- cliApiKeyEnv?: string;
432
- cliBaseUrl?: string;
433
400
  }
434
401
 
435
- const MVP_CHANNELS = new Set(['prompt', 'code_tool_hook', 'defer_archive']);
436
-
437
402
  export async function buildDoctorOutput(input: BuildDoctorInput): Promise<DoctorOutput> {
438
403
  const workspaceDir = path.resolve(input.workspaceDir);
439
- const pdDir = path.join(workspaceDir, FEATURE_FLAGS_CONFIG_DIR);
440
- const featureFlagsPath = path.join(pdDir, FEATURE_FLAGS_CONFIG_FILENAME);
441
- const workflowsPath = path.join(workspaceDir, '.state', 'workflows.yaml');
404
+ const pdDir = path.join(workspaceDir, '.pd');
405
+ const configYamlPath = getPdConfigPath(workspaceDir);
442
406
  const stateDbPath = path.join(pdDir, 'state.db');
443
407
 
444
- // 1) Feature flags
445
- let enabledMvpChannels: string[] = [];
446
- let disabledFlags: string[] = [];
447
- let flagSource: string;
448
- let flagWarnings: string[];
449
- let hasFeatureFlagsError = false;
450
- let featureFlagsErrorMessage = '';
408
+ // 1) Load PD config from .pd/config.yaml
409
+ const loadResult = loadPdConfig(workspaceDir);
410
+ const flags = computeFlagsFromLoadResult(loadResult);
411
+ const redacted = redactLoadResult(loadResult);
412
+
413
+ // 2) Feature flag summary
414
+ const enabledMvpChannels: string[] = [];
415
+ const disabledFlags: string[] = [];
416
+ for (const flag of Object.values(flags.flags)) {
417
+ if (flag.enabled && MVP_CHANNEL_IDS.includes(flag.id as typeof MVP_CHANNEL_IDS[number])) {
418
+ enabledMvpChannels.push(flag.id);
419
+ } else if (!flag.enabled) {
420
+ disabledFlags.push(flag.id);
421
+ }
422
+ }
451
423
 
452
- let correctionObserverEnabled = true;
453
- let coFlagSource = 'defaults';
424
+ const featureFlags: FeatureFlagSummary = {
425
+ source: loadResult.ok ? loadResult.source : 'malformed',
426
+ configPath: loadResult.configPath,
427
+ enabledMvpChannels,
428
+ disabledFlags,
429
+ warnings: flags.warnings,
430
+ };
454
431
 
455
- try {
456
- const flags = loadEffectiveFeatureFlags(workspaceDir);
457
- flagSource = flags.source;
458
- flagWarnings = [...flags.warnings];
459
- for (const flag of Object.values(flags.flags)) {
460
- if (flag.enabled && MVP_CHANNELS.has(flag.id)) {
461
- enabledMvpChannels.push(flag.id);
462
- } else if (!flag.enabled) {
463
- disabledFlags.push(flag.id);
464
- }
465
- }
466
- if (flags.flags && flags.flags.correction_observer) {
467
- correctionObserverEnabled = flags.flags.correction_observer.enabled;
468
- coFlagSource = flags.source;
469
- }
470
- } catch (err) {
471
- hasFeatureFlagsError = true;
472
- featureFlagsErrorMessage = err instanceof Error ? err.message : String(err);
473
- flagSource = 'unavailable';
474
- flagWarnings = [`feature flags unavailable: ${featureFlagsErrorMessage}`];
475
- coFlagSource = 'unavailable';
432
+ // 3) Internal agent diagnostics from .pd/config.yaml
433
+ const profileMap = new Map<string, RedactedRuntimeProfileSummary>();
434
+ for (const p of redacted.runtimeProfiles) {
435
+ profileMap.set(p.id, p);
476
436
  }
477
437
 
478
- // 2) Provider config from workflows.yaml (or CLI override)
479
- const providerCfg = await resolveProviderConfigFromWorkflows(
480
- path.join(workspaceDir, '.state'),
481
- {
482
- cliProvider: input.cliProvider,
483
- cliModel: input.cliModel,
484
- cliApiKeyEnv: input.cliApiKeyEnv,
485
- cliBaseUrl: input.cliBaseUrl,
486
- },
487
- );
438
+ const internalAgents: InternalAgentDiagnostics[] = [];
439
+ for (const agent of redacted.agents) {
440
+ const profile = profileMap.get(agent.runtimeProfileId);
441
+ internalAgents.push(diagnoseInternalAgent(agent, profile));
442
+ }
488
443
 
489
- // 3) Build the provider health entry. NEVER leak env var value.
444
+ // 4) Provider health derived from internal agents that are enabled
490
445
  const providerHealth: ProviderHealthEntry[] = [];
491
446
  const warnings: string[] = [];
492
447
  const nextActions: string[] = [];
493
448
 
494
- if (providerCfg.source === 'missing' && !input.cliProvider) {
495
- providerHealth.push({
496
- provider: null,
497
- model: null,
498
- apiKeyEnv: null,
499
- apiKeyPresent: false,
500
- classification: 'config_missing',
501
- reason: 'No provider configured (workflows.yaml funnel policy missing and no CLI override)',
502
- nextAction: 'Configure provider/model/apiKeyEnv in workflows.yaml pd-runtime-v2-diagnosis policy, or pass --provider/--model/--apiKeyEnv flags',
503
- source: providerCfg.source,
504
- });
505
- warnings.push('No provider configured for the diagnostic funnel');
506
- nextActions.push('Configure provider/model/apiKeyEnv in workflows.yaml or pass CLI flags');
507
- } else {
508
- const apiKeyEnvName = providerCfg.apiKeyEnv;
509
- const apiKeyPresent = !!apiKeyEnvName && Object.prototype.hasOwnProperty.call(process.env, apiKeyEnvName) && !!process.env[apiKeyEnvName];
510
-
511
- let classification: DoctorClassification;
512
- let reason: string;
513
- let nextAction: string;
514
-
515
- if (!providerCfg.provider || !providerCfg.model) {
516
- classification = 'config_missing';
517
- reason = !providerCfg.provider
518
- ? 'Provider not configured'
519
- : 'Model not configured';
520
- nextAction = 'Set provider and model in workflows.yaml funnel policy or pass --provider/--model flags';
521
- } else if (!apiKeyEnvName) {
522
- classification = 'auth_missing';
523
- reason = 'apiKeyEnv is not set in workflows.yaml';
524
- nextAction = "Add 'apiKeyEnv: <ENV_VAR_NAME>' to workflows.yaml funnel policy and ensure the env var holds a valid key";
525
- } else if (!apiKeyPresent) {
526
- classification = 'auth_missing';
527
- reason = `Environment variable '${apiKeyEnvName}' is not set or empty`;
528
- nextAction = `Set the environment variable '${apiKeyEnvName}' with a valid API key, then retry`;
529
- } else {
530
- // Config is well-formed and the key is present — check state.db for rate-limit / unavailable signals.
531
- const signalResult = await inspectStateDbForProviderSignal(stateDbPath);
532
- if (signalResult.warning) {
533
- warnings.push(signalResult.warning);
534
- }
535
- if (signalResult.signal) {
536
- const { classification: cls, reason: rsn } = signalResult.signal;
537
- classification = cls;
538
- reason = rsn;
539
- switch (classification) {
540
- case 'rate_limit':
541
- nextAction = `Wait for the provider's rate limit to reset, or reduce request rate. Recent error in state.db.`;
542
- break;
543
- case 'auth_missing':
544
- nextAction = `The env var is set, but the provider rejected the call. Verify the key value is current and has access to model '${providerCfg.model}'.`;
545
- break;
546
- case 'unavailable':
547
- nextAction = `Provider/model temporarily unavailable. Try a different model or retry later. Check provider status page.`;
548
- break;
549
- default:
550
- nextAction = 'Inspect recent task errors in state.db for details.';
551
- }
552
- } else {
553
- classification = 'healthy';
554
- reason = 'Provider, model, and apiKeyEnv are configured; env var is set; no recent error signals';
555
- nextAction = 'Run pd diagnose run to validate end-to-end provider connectivity';
556
- }
449
+ // Add config-level warnings
450
+ warnings.push(...loadResult.warnings);
451
+ warnings.push(...flags.warnings);
452
+
453
+ if (!loadResult.ok) {
454
+ for (const err of loadResult.errors) {
455
+ warnings.push(`Config error at ${err.path}: ${err.reason}`);
557
456
  }
457
+ nextActions.push(loadResult.errors[0]?.nextAction ?? 'Fix .pd/config.yaml and retry');
458
+ }
558
459
 
460
+ // Check for enabled agents that need setup
461
+ const needsSetupAgents = internalAgents.filter(a => a.enabled && (a.readiness === 'needs_setup' || a.readiness === 'not_ready'));
462
+ for (const agent of needsSetupAgents) {
463
+ // not_ready = key present but not probed → needs_probe (degraded), NOT auth_missing (failed)
464
+ const classification: DoctorClassification = agent.readiness === 'needs_setup'
465
+ ? 'config_missing'
466
+ : 'needs_probe';
559
467
  providerHealth.push({
560
- provider: providerCfg.provider,
561
- model: providerCfg.model,
562
- apiKeyEnv: apiKeyEnvName,
563
- apiKeyPresent,
468
+ provider: null,
469
+ model: null,
470
+ apiKeyEnv: agent.apiKeyEnv,
471
+ apiKeyPresent: agent.apiKeyPresent,
564
472
  classification,
565
- reason,
566
- nextAction,
567
- source: providerCfg.source,
473
+ reason: agent.reason,
474
+ nextAction: agent.nextAction,
475
+ source: 'config.yaml',
568
476
  });
569
477
  }
570
478
 
571
- if (providerCfg.parseWarning) {
572
- warnings.push(providerCfg.parseWarning);
573
- if (!providerCfg.workflowsFound) {
574
- nextActions.push('Create workflows.yaml in <workspace>/.state with a pd-runtime-v2-diagnosis funnel policy');
575
- } else {
576
- nextActions.push('Fix workflows.yaml parse error — see warnings for details');
577
- }
578
- }
579
-
580
- // 4) Aggregate feature-flag warnings
581
- for (const w of flagWarnings) {
582
- warnings.push(w);
583
- }
584
- if (flagWarnings.length > 0 && !hasFeatureFlagsError) {
585
- nextActions.push('Review .pd/feature-flags.yaml — see warnings for details');
586
- }
587
- if (hasFeatureFlagsError) {
588
- warnings.push(`feature flags unavailable: ${featureFlagsErrorMessage}`);
589
- nextActions.push(`Check that ${featureFlagsPath} is a readable file, not a directory.`);
590
- nextActions.push('Re-run npx create-principles-disciple if the config was generated incorrectly.');
591
- }
592
-
593
- // 4.5) CorrectionObserver Diagnostics
594
- let coStatus: 'disabled' | 'configured' | 'auth_missing' | 'config_missing' | 'unavailable';
595
- let coConfigSource: 'workflows.yaml' | 'env' | 'missing' | 'unavailable';
596
- let coProvider: string | null = null;
597
- let coModel: string | null = null;
598
- let coApiKeyEnv: string | null = null;
599
- let coApiKeyPresent = false;
600
- let coReason: string;
601
- let coNextAction: string;
602
-
603
- if (!correctionObserverEnabled) {
604
- coStatus = 'disabled';
605
- coConfigSource = 'missing';
606
- coProvider = null;
607
- coModel = null;
608
- coApiKeyEnv = null;
609
- coApiKeyPresent = false;
610
- coReason = 'CorrectionObserver is disabled via feature flags';
611
- coNextAction = 'Set correction_observer.enabled=true in .pd/feature-flags.yaml to enable it';
612
- } else {
613
- const coWorkflowsPath = path.join(workspaceDir, '.state', 'workflows.yaml');
614
- let coWorkflowsFound = false;
615
- let coWorkflowsParseError = false;
616
- let coWorkflowsParseErrorMessage = '';
617
- let coWorkflowPolicy: Record<string, unknown> | null = null;
618
-
619
- if (fs.existsSync(coWorkflowsPath)) {
620
- coWorkflowsFound = true;
621
- try {
622
- const raw = fs.readFileSync(coWorkflowsPath, 'utf8');
623
- const parsed = yaml.load(raw);
624
- if (isRecord(parsed)) {
625
- const funnelsRaw = parsed.funnels;
626
- if (Array.isArray(funnelsRaw)) {
627
- for (const f of funnelsRaw) {
628
- if (isRecord(f) && f.workflowId === 'pd-correction-observer' && isRecord(f.policy)) {
629
- coWorkflowPolicy = f.policy;
630
- break;
631
- }
632
- }
633
- }
634
- }
635
- } catch (err) {
636
- coWorkflowsParseError = true;
637
- coWorkflowsParseErrorMessage = err instanceof Error ? err.message : String(err);
638
- }
479
+ // Check state.db for rate-limit / unavailable signals for enabled agents
480
+ const readyAgents = internalAgents.filter(a => a.enabled && a.readiness === 'ready');
481
+ if (readyAgents.length > 0) {
482
+ const signalResult = await inspectStateDbForProviderSignal(stateDbPath);
483
+ if (signalResult.warning) {
484
+ warnings.push(signalResult.warning);
639
485
  }
640
-
641
- if (coWorkflowsParseError) {
642
- coStatus = 'unavailable';
643
- coConfigSource = 'unavailable';
644
- coReason = `workflows.yaml parse failure: ${coWorkflowsParseErrorMessage}`;
645
- coNextAction = 'Fix workflows.yaml syntax or file access permissions';
646
- } else if (coWorkflowsFound && coWorkflowPolicy && coWorkflowPolicy.runtimeKind === 'pi-ai') {
647
- coConfigSource = 'workflows.yaml';
648
- coProvider = typeof coWorkflowPolicy.provider === 'string' ? coWorkflowPolicy.provider : null;
649
- coModel = typeof coWorkflowPolicy.model === 'string' ? coWorkflowPolicy.model : null;
650
- coApiKeyEnv = typeof coWorkflowPolicy.apiKeyEnv === 'string' ? coWorkflowPolicy.apiKeyEnv : null;
651
- coApiKeyPresent = !!coApiKeyEnv && Object.prototype.hasOwnProperty.call(process.env, coApiKeyEnv) && !!process.env[coApiKeyEnv];
652
-
653
- if (!coProvider || !coModel) {
654
- coStatus = 'config_missing';
655
- coReason = !coProvider ? 'Provider not configured in workflows.yaml policy' : 'Model not configured in workflows.yaml policy';
656
- coNextAction = 'Set provider and model in pd-correction-observer policy in workflows.yaml';
657
- } else if (!coApiKeyEnv) {
658
- coStatus = 'auth_missing';
659
- coReason = 'apiKeyEnv is not set in workflows.yaml pd-correction-observer policy';
660
- coNextAction = "Add 'apiKeyEnv: <ENV_VAR_NAME>' to workflows.yaml pd-correction-observer policy and ensure the env var holds a valid key";
661
- } else if (!coApiKeyPresent) {
662
- coStatus = 'auth_missing';
663
- coReason = `Environment variable '${coApiKeyEnv}' is not set or empty`;
664
- coNextAction = `Set the environment variable '${coApiKeyEnv}' with a valid API key, or disable correction_observer in feature flags`;
665
- } else {
666
- coStatus = 'configured';
667
- coReason = 'CorrectionObserver is configured and ready via workflows.yaml';
668
- coNextAction = 'No action required; correction observer is active';
669
- }
486
+ if (signalResult.signal) {
487
+ const { classification: cls, reason: rsn } = signalResult.signal;
488
+ providerHealth.push({
489
+ provider: null,
490
+ model: null,
491
+ apiKeyEnv: null,
492
+ apiKeyPresent: false,
493
+ classification: cls,
494
+ reason: rsn,
495
+ nextAction: cls === 'rate_limit'
496
+ ? "Wait for the provider's rate limit to reset, or reduce request rate"
497
+ : 'Provider/model temporarily unavailable. Try a different model or retry later.',
498
+ source: 'state.db',
499
+ });
670
500
  } else {
671
- coConfigSource = 'env';
672
- coProvider = process.env.PD_CORRECTION_PROVIDER || 'anthropic';
673
- coModel = process.env.PD_CORRECTION_MODEL || 'anthropic/claude-3-5-sonnet';
674
- coApiKeyEnv = process.env.PD_CORRECTION_API_KEY_ENV || 'ANTHROPIC_API_KEY';
675
- coApiKeyPresent = !!coApiKeyEnv && Object.prototype.hasOwnProperty.call(process.env, coApiKeyEnv) && !!process.env[coApiKeyEnv];
676
-
677
- if (!coApiKeyPresent) {
678
- coStatus = 'auth_missing';
679
- coReason = `Environment variable '${coApiKeyEnv}' is not set or empty`;
680
- coNextAction = `Set the environment variable '${coApiKeyEnv}' with a valid API key, or configure workflows.yaml, or disable correction_observer in feature flags`;
681
- } else {
682
- coStatus = 'configured';
683
- coReason = 'CorrectionObserver is configured and ready via env overrides/defaults';
684
- coNextAction = 'No action required; correction observer is active';
685
- }
501
+ providerHealth.push({
502
+ provider: null,
503
+ model: null,
504
+ apiKeyEnv: null,
505
+ apiKeyPresent: false,
506
+ classification: 'healthy',
507
+ reason: 'Config is valid; no recent error signals in state.db',
508
+ nextAction: 'Run pd runtime probe to verify end-to-end connectivity',
509
+ source: 'config.yaml',
510
+ });
686
511
  }
687
512
  }
688
513
 
689
514
  // 5) Compute overall status
690
515
  let status: DoctorStatus = 'ok';
691
516
  const classifications = providerHealth.map((p) => p.classification);
692
- if (classifications.includes('rate_limit') || classifications.includes('unavailable')) {
517
+ if (classifications.includes('rate_limit') || classifications.includes('unavailable') || classifications.includes('needs_probe')) {
693
518
  status = 'degraded';
694
519
  }
695
520
  if (classifications.includes('auth_missing') || classifications.includes('config_missing')) {
696
521
  status = 'failed';
697
522
  }
698
- if ((warnings.length > 0 || hasFeatureFlagsError) && status === 'ok') {
523
+ if (!loadResult.ok) {
524
+ status = 'failed';
525
+ }
526
+ if ((warnings.length > 0) && status === 'ok') {
699
527
  status = 'degraded';
700
528
  }
701
529
 
702
530
  // 6) Reason
703
531
  let reason: string | undefined;
704
532
  if (status === 'failed') {
705
- const failed = providerHealth.filter((p) => p.classification === 'auth_missing' || p.classification === 'config_missing');
706
- if (failed.length > 0) {
707
- reason = `Provider auth/config missing: ${failed.map((p) => p.classification).join(', ')}`;
533
+ if (!loadResult.ok) {
534
+ reason = `Config validation failed: ${loadResult.errors.map(e => e.reason).join('; ')}`;
708
535
  } else {
709
- reason = 'Configuration is missing required fields';
536
+ const failed = providerHealth.filter((p) => p.classification === 'auth_missing' || p.classification === 'config_missing');
537
+ reason = failed.length > 0
538
+ ? `Provider auth/config missing: ${failed.map((p) => p.classification).join(', ')}`
539
+ : 'Configuration is missing required fields';
710
540
  }
711
541
  } else if (status === 'degraded') {
712
- const degraded = providerHealth.filter((p) => p.classification === 'rate_limit' || p.classification === 'unavailable');
542
+ const degraded = providerHealth.filter((p) => p.classification === 'rate_limit' || p.classification === 'unavailable' || p.classification === 'needs_probe');
713
543
  if (degraded.length > 0) {
714
544
  reason = `Provider connectivity degraded: ${degraded.map((p) => p.classification).join(', ')}`;
715
545
  } else if (warnings.length > 0) {
@@ -717,45 +547,26 @@ export async function buildDoctorOutput(input: BuildDoctorInput): Promise<Doctor
717
547
  }
718
548
  }
719
549
 
720
- // 7) Build path entries
550
+ // 7) Build output
721
551
  const out: DoctorOutput = {
722
552
  status,
723
553
  workspaceDir,
724
554
  pdConfigPaths: {
725
555
  workspaceDir: pathEntry(workspaceDir),
726
556
  pdDir: pathEntry(pdDir),
727
- featureFlags: pathEntry(featureFlagsPath),
728
- workflowsYaml: pathEntry(workflowsPath),
557
+ configYaml: { ...pathEntry(configYamlPath), parseable: loadResult.ok || !fs.existsSync(configYamlPath) ? true : false },
729
558
  stateDb: pathEntry(stateDbPath),
730
559
  },
731
560
  openclawConfigPaths: {
732
561
  openclawHome: pathEntry(getOpenClawHome()),
733
562
  openclawConfig: pathEntry(getOpenClawConfigPath()),
734
563
  },
735
- featureFlags: {
736
- source: flagSource,
737
- configPath: featureFlagsPath,
738
- enabledMvpChannels,
739
- disabledFlags,
740
- warnings: flagWarnings,
741
- },
564
+ featureFlags,
565
+ internalAgents,
742
566
  providerHealth,
743
- internalAgents: {
744
- correctionObserver: {
745
- enabled: correctionObserverEnabled,
746
- flagSource: coFlagSource,
747
- status: coStatus,
748
- configSource: coConfigSource,
749
- provider: coProvider,
750
- model: coModel,
751
- apiKeyEnv: coApiKeyEnv,
752
- apiKeyPresent: coApiKeyPresent,
753
- reason: coReason,
754
- nextAction: coNextAction,
755
- },
756
- },
757
567
  warnings,
758
- nextActions: nextActions.length > 0 ? nextActions : ['All checks passed — provider is configured and reachable'],
568
+ nextActions: nextActions.length > 0 ? nextActions : ['All checks passed — configuration is valid'],
569
+ legacyFilesDetected: loadResult.legacyFilesDetected,
759
570
  };
760
571
 
761
572
  if (reason) out.reason = reason;