@principles/pd-cli 1.128.1 → 1.129.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 (38) hide show
  1. package/dist/commands/mvp-smoke.js +1 -1
  2. package/dist/commands/mvp-smoke.js.map +1 -1
  3. package/dist/commands/rulecode.js +1 -1
  4. package/dist/commands/rulecode.js.map +1 -1
  5. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  6. package/dist/commands/runtime-internalization-run-rulehost.js +61 -7
  7. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  8. package/dist/commands/runtime.js +1 -1
  9. package/dist/commands/runtime.js.map +1 -1
  10. package/dist/index.js +67 -34
  11. package/dist/index.js.map +1 -1
  12. package/dist/services/__tests__/rulehost-readiness.test.d.ts +2 -0
  13. package/dist/services/__tests__/rulehost-readiness.test.d.ts.map +1 -0
  14. package/dist/services/__tests__/rulehost-readiness.test.js +314 -0
  15. package/dist/services/__tests__/rulehost-readiness.test.js.map +1 -0
  16. package/dist/services/rulehost-readiness.d.ts +62 -0
  17. package/dist/services/rulehost-readiness.d.ts.map +1 -0
  18. package/dist/services/rulehost-readiness.js +214 -0
  19. package/dist/services/rulehost-readiness.js.map +1 -0
  20. package/package.json +1 -1
  21. package/src/commands/mvp-smoke.ts +1 -1
  22. package/src/commands/rulecode.ts +1 -1
  23. package/src/commands/runtime-internalization-run-rulehost.ts +68 -6
  24. package/src/commands/runtime.ts +1 -1
  25. package/src/index.ts +73 -36
  26. package/src/services/__tests__/rulehost-readiness.test.ts +366 -0
  27. package/src/services/rulehost-readiness.ts +326 -0
  28. package/tests/commands/cli-command-tree.test.ts +2 -2
  29. package/tests/commands/cli-help-snapshot.test.ts +135 -0
  30. package/tests/commands/cli-skill-contract.test.ts +121 -0
  31. package/tests/commands/run-rulehost-handler.test.ts +277 -0
  32. package/tests/commands/runtime-internalization.test.ts +2 -2
  33. package/tests/services/rulehost-pipeline-e2e.test.ts +71 -61
  34. package/dist/commands/central-sync.d.ts +0 -10
  35. package/dist/commands/central-sync.d.ts.map +0 -1
  36. package/dist/commands/central-sync.js +0 -32
  37. package/dist/commands/central-sync.js.map +0 -1
  38. package/src/commands/central-sync.ts +0 -44
@@ -49,9 +49,9 @@ describe('CLI command tree structure', () => {
49
49
  expect(output).toContain('UAT');
50
50
  });
51
51
 
52
- it('runtime subcommand list includes uat (pd runtime --help)', () => {
52
+ it('runtime subcommand list does NOT include uat (PRI-455: hidden from --help)', () => {
53
53
  const output = runPdHelp(['runtime', '--help']);
54
- expect(output).toMatch(/uat\s/);
54
+ expect(output).not.toMatch(/uat\s/);
55
55
  });
56
56
 
57
57
  it('pruning subcommand list does NOT include uat (pd runtime pruning --help)', () => {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * PRI-455: CLI help snapshot test — pins the visible command set.
3
+ *
4
+ * Asserts that `pd --help` shows only MVP owner-facing commands,
5
+ * and that operator/debug commands are hidden (de-surfaced, not deleted).
6
+ *
7
+ * Hidden commands still work when invoked explicitly — this test only
8
+ * checks --help visibility, not functional behavior.
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { getBuiltPdCliPath } from '../helpers/pd-cli-path.js';
13
+
14
+ function runPdHelp(args: string[]): string {
15
+ try {
16
+ return execFileSync('node', [getBuiltPdCliPath(), ...args], {
17
+ encoding: 'utf8',
18
+ cwd: process.cwd(),
19
+ });
20
+ } catch (err: unknown) {
21
+ if (err && typeof err === 'object' && Object.hasOwn(err, 'stdout')) {
22
+ return String(Reflect.get(err, 'stdout'));
23
+ }
24
+ throw err;
25
+ }
26
+ }
27
+
28
+ describe('PRI-455: pd --help shows only MVP owner commands', () => {
29
+ const helpOutput = runPdHelp(['--help']);
30
+
31
+ // ── Owner-facing commands that MUST be visible ──────────────────────────
32
+ const OWNER_COMMANDS = [
33
+ 'pain', // Step 1: Capture
34
+ 'diagnose', // Step 2: Diagnose
35
+ 'candidate', // Step 3: Proposal
36
+ 'console', // Step 4: Review
37
+ 'activation', // Step 5-6: Activate & Observe (promoted)
38
+ 'trace', // Step 6: Observe (promoted)
39
+ 'health', // Global health
40
+ 'config', // Onboarding
41
+ 'task', // Async diagnosis progress
42
+ 'runtime', // runtime features (MVP-Core flag verification)
43
+ ];
44
+
45
+ for (const cmd of OWNER_COMMANDS) {
46
+ it(`pd --help shows owner command: ${cmd}`, () => {
47
+ // Anchor to line beginnings (like the hidden-command check) so the
48
+ // assertion matches the command list entry, not description text.
49
+ expect(helpOutput).toMatch(new RegExp(`^\\s+${cmd}\\b`, 'm'));
50
+ });
51
+ }
52
+
53
+ // ── Operator commands that MUST be hidden from --help ───────────────────
54
+ const HIDDEN_COMMANDS = [
55
+ 'samples',
56
+ 'evolution',
57
+ 'central', // MVP-Gone, deleted
58
+ 'trajectory',
59
+ 'history',
60
+ 'context',
61
+ 'legacy',
62
+ 'artifact',
63
+ 'demo',
64
+ 'mvp',
65
+ 'quality',
66
+ 'rulecode',
67
+ ];
68
+
69
+ for (const cmd of HIDDEN_COMMANDS) {
70
+ it(`pd --help does NOT show operator command: ${cmd}`, () => {
71
+ expect(helpOutput).not.toMatch(new RegExp(`^\\s+${cmd}\\b`, 'm'));
72
+ });
73
+ }
74
+ });
75
+
76
+ describe('PRI-455: pd runtime --help shows only MVP owner subcommands', () => {
77
+ const runtimeHelp = runPdHelp(['runtime', '--help']);
78
+
79
+ // Owner-facing subcommands under runtime
80
+ it('runtime --help shows features', () => {
81
+ expect(runtimeHelp).toMatch(/\bfeatures\b/);
82
+ });
83
+
84
+ // Operator subcommands that should be hidden
85
+ const HIDDEN_RUNTIME_SUBCOMMANDS = [
86
+ 'canary',
87
+ 'synthetic',
88
+ 'uat',
89
+ 'internalization',
90
+ 'recovery',
91
+ 'pruning',
92
+ 'diagnostics',
93
+ 'probe',
94
+ 'flow',
95
+ ];
96
+
97
+ for (const cmd of HIDDEN_RUNTIME_SUBCOMMANDS) {
98
+ it(`runtime --help does NOT show operator subcommand: ${cmd}`, () => {
99
+ expect(runtimeHelp).not.toMatch(new RegExp(`^\\s+${cmd}\\b`, 'm'));
100
+ });
101
+ }
102
+ });
103
+
104
+ describe('PRI-455: promoted commands work at top-level', () => {
105
+ it('pd trace --help shows trace subcommand', () => {
106
+ const output = runPdHelp(['trace', '--help']);
107
+ expect(output).toContain('show');
108
+ });
109
+
110
+ it('pd activation --help shows list and deactivate', () => {
111
+ const output = runPdHelp(['activation', '--help']);
112
+ expect(output).toContain('list');
113
+ expect(output).toContain('deactivate');
114
+ });
115
+ });
116
+
117
+ describe('PRI-455: hidden commands still function (de-surface, not delete)', () => {
118
+ it('pd runtime probe --help still works (hidden but callable)', () => {
119
+ const output = runPdHelp(['runtime', 'probe', '--help']);
120
+ expect(output).toContain('--workspace');
121
+ expect(output).toContain('--json');
122
+ });
123
+
124
+ it('pd runtime internalization queue --help still works', () => {
125
+ const output = runPdHelp(['runtime', 'internalization', 'queue', '--help']);
126
+ expect(output).toContain('--workspace');
127
+ expect(output).toContain('--json');
128
+ });
129
+
130
+ it('pd legacy cleanup --help still works (hidden but callable)', () => {
131
+ const output = runPdHelp(['legacy', 'cleanup', '--help']);
132
+ expect(output).toContain('--dry-run');
133
+ expect(output).toContain('--apply');
134
+ });
135
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * PRI-455: SKILL ↔ CLI contract test.
3
+ *
4
+ * Parses SKILL.md templates for `pd <command>` references and verifies
5
+ * that each referenced command path exists in the CLI command tree.
6
+ *
7
+ * This prevents SKILL templates from referencing deleted or renamed commands.
8
+ *
9
+ * Covers BOTH `en` and `zh` skill directories so bilingual template updates
10
+ * are validated. Fails explicitly (does not silently skip) when the expected
11
+ * skills directory is missing — a missing directory is a real contract break.
12
+ */
13
+ import { describe, it, expect } from 'vitest';
14
+ import { execFileSync } from 'node:child_process';
15
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ import { getBuiltPdCliPath } from '../helpers/pd-cli-path.js';
18
+
19
+ /**
20
+ * Resolve the skills directory for a given language.
21
+ * Tests run from packages/pd-cli, so the plugin templates live one level up.
22
+ */
23
+ function resolveSkillsDir(lang: 'en' | 'zh'): string {
24
+ return resolve(
25
+ process.cwd(),
26
+ '..',
27
+ 'openclaw-plugin',
28
+ 'templates',
29
+ 'langs',
30
+ lang,
31
+ 'skills',
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Extract `pd <command> [subcommand] ...` patterns from SKILL.md content.
37
+ * Matches lines like:
38
+ * pd pain record --reason ...
39
+ * pd runtime probe --json
40
+ * pd candidate list --workspace ...
41
+ * Returns full command paths (e.g. "pain record", "runtime probe").
42
+ */
43
+ function extractPdCommands(markdown: string): string[] {
44
+ const commands: string[] = [];
45
+ // Match `pd <words>` in code blocks and inline code
46
+ const pdPattern = /`pd\s+([\w-]+(?:\s+[\w-]+)*)/g;
47
+ let match: RegExpExecArray | null;
48
+ while ((match = pdPattern.exec(markdown)) !== null) {
49
+ // Take up to 3 path segments (e.g. "runtime internalization queue")
50
+ const parts = match[1].split(/\s+/).slice(0, 3);
51
+ // Filter out flags and options
52
+ const cleanParts = parts.filter((p) => !p.startsWith('--') && !p.startsWith('<'));
53
+ if (cleanParts.length > 0) {
54
+ commands.push(cleanParts.join(' '));
55
+ }
56
+ }
57
+ return [...new Set(commands)];
58
+ }
59
+
60
+ /**
61
+ * Check if a command path exists by running `pd <path> --help`.
62
+ * Returns true if the command is registered (exit code 0 or help output contains "Usage:").
63
+ */
64
+ function commandExists(commandPath: string): boolean {
65
+ const args = commandPath.split(' ');
66
+ try {
67
+ const output = execFileSync('node', [getBuiltPdCliPath(), ...args, '--help'], {
68
+ encoding: 'utf8',
69
+ cwd: process.cwd(),
70
+ timeout: 10000,
71
+ stdio: ['pipe', 'pipe', 'pipe'],
72
+ });
73
+ return output.includes('Usage:') || output.includes('Options:');
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ // Collect (lang, skill, command) triples across both en and zh.
80
+ // A missing skills directory is a real contract break — fail explicitly.
81
+ const LANGS = ['en', 'zh'] as const;
82
+ const skillCommandPairs: { lang: string; skill: string; command: string }[] = [];
83
+ const missingLangDirs: string[] = [];
84
+
85
+ for (const lang of LANGS) {
86
+ const skillsDir = resolveSkillsDir(lang);
87
+ if (!existsSync(skillsDir)) {
88
+ missingLangDirs.push(lang);
89
+ continue;
90
+ }
91
+ const skillDirs = readdirSync(skillsDir).filter((dir) =>
92
+ existsSync(join(skillsDir, dir, 'SKILL.md')),
93
+ );
94
+ for (const dir of skillDirs) {
95
+ const skillPath = join(skillsDir, dir, 'SKILL.md');
96
+ const content = readFileSync(skillPath, 'utf8');
97
+ const commands = extractPdCommands(content);
98
+ for (const cmd of commands) {
99
+ skillCommandPairs.push({ lang, skill: dir, command: cmd });
100
+ }
101
+ }
102
+ }
103
+
104
+ describe('PRI-455: SKILL ↔ CLI contract', () => {
105
+ it('both en and zh skills directories exist', () => {
106
+ // Explicit failure instead of silent skip — a missing directory means
107
+ // the contract is no longer being validated for that language.
108
+ expect(missingLangDirs, `missing skills directories: ${missingLangDirs.join(', ')}`).toEqual([]);
109
+ });
110
+
111
+ it('found SKILL files that reference pd commands', () => {
112
+ expect(skillCommandPairs.length).toBeGreaterThan(0);
113
+ });
114
+
115
+ // Test each SKILL-referenced command exists in the CLI
116
+ for (const { lang, skill, command } of skillCommandPairs) {
117
+ it(`[${lang}] SKILL "${skill}" references valid command: pd ${command}`, () => {
118
+ expect(commandExists(command)).toBe(true);
119
+ });
120
+ }
121
+ });
@@ -71,6 +71,11 @@ function parseJsonObject(text: string): Record<string, unknown> {
71
71
  return parsed;
72
72
  }
73
73
 
74
+ /** Type guard: narrows `unknown` to `Record<string, unknown>` without `as`. */
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
77
+ }
78
+
74
79
  function captureStdio(fn: () => Promise<void>): Promise<StdIoState> {
75
80
  return new Promise((resolve, reject) => {
76
81
  const origExitCode = process.exitCode;
@@ -251,3 +256,275 @@ describe('handleRunRuleHost — dry-run mode output shape (with minimal pd-confi
251
256
  }
252
257
  });
253
258
  });
259
+
260
+ // ── PRI-461: readiness integration tests ──────────────────────────────────
261
+ //
262
+ // Verifies the handler emits the three readiness statuses (ready /
263
+ // text_principle_only / refused) with the correct exit codes and JSON shape.
264
+ // These tests exercise the full path: config → resolveRuleHostReadiness →
265
+ // handler output, ensuring the readiness gate is wired into production code
266
+ // (EP-02) and that refused statuses fail loud with reason + nextAction (EP-03).
267
+
268
+ function writeFullReadyConfig(workspaceDir: string): void {
269
+ const configDir = path.join(workspaceDir, '.pd');
270
+ fs.mkdirSync(configDir, { recursive: true });
271
+ const cfg = {
272
+ version: 1,
273
+ features: {
274
+ prompt: { category: 'core', enabled: true },
275
+ code_tool_hook: { category: 'core', enabled: true },
276
+ defer_archive: { category: 'core', enabled: true },
277
+ code_rule_capability: { category: 'core', enabled: true },
278
+ },
279
+ workspace: { default: workspaceDir },
280
+ runtimeProfiles: {
281
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
282
+ },
283
+ internalAgents: {
284
+ defaultRuntime: 'pi-ai.default',
285
+ agents: {
286
+ dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
287
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
288
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
289
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
290
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
291
+ },
292
+ },
293
+ };
294
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
295
+ }
296
+
297
+ function writeTextPrincipleOnlyConfig(workspaceDir: string): void {
298
+ // code_rule_capability explicitly OFF → text_principle_only
299
+ const configDir = path.join(workspaceDir, '.pd');
300
+ fs.mkdirSync(configDir, { recursive: true });
301
+ const cfg = {
302
+ version: 1,
303
+ features: {
304
+ prompt: { category: 'core', enabled: true },
305
+ code_tool_hook: { category: 'core', enabled: true },
306
+ defer_archive: { category: 'core', enabled: true },
307
+ code_rule_capability: { category: 'core', enabled: false },
308
+ },
309
+ workspace: { default: workspaceDir },
310
+ runtimeProfiles: {
311
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
312
+ },
313
+ internalAgents: {
314
+ defaultRuntime: 'pi-ai.default',
315
+ agents: {
316
+ dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
317
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
318
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
319
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
320
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
321
+ },
322
+ },
323
+ };
324
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
325
+ }
326
+
327
+ function writeEvaluatorDisabledConfig(workspaceDir: string): void {
328
+ const configDir = path.join(workspaceDir, '.pd');
329
+ fs.mkdirSync(configDir, { recursive: true });
330
+ const cfg = {
331
+ version: 1,
332
+ features: {
333
+ prompt: { category: 'core', enabled: true },
334
+ code_tool_hook: { category: 'core', enabled: true },
335
+ defer_archive: { category: 'core', enabled: true },
336
+ code_rule_capability: { category: 'core', enabled: true },
337
+ },
338
+ workspace: { default: workspaceDir },
339
+ runtimeProfiles: {
340
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
341
+ },
342
+ internalAgents: {
343
+ defaultRuntime: 'pi-ai.default',
344
+ agents: {
345
+ dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
346
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
347
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
348
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
349
+ evaluator: { enabled: false, runtimeProfile: 'pi-ai.default' },
350
+ },
351
+ },
352
+ };
353
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
354
+ }
355
+
356
+ function writeRefusedConfig(workspaceDir: string): void {
357
+ // dreamer disabled → required agent missing → refused
358
+ const configDir = path.join(workspaceDir, '.pd');
359
+ fs.mkdirSync(configDir, { recursive: true });
360
+ const cfg = {
361
+ version: 1,
362
+ features: {
363
+ prompt: { category: 'core', enabled: true },
364
+ code_tool_hook: { category: 'core', enabled: true },
365
+ defer_archive: { category: 'core', enabled: true },
366
+ code_rule_capability: { category: 'core', enabled: true },
367
+ },
368
+ workspace: { default: workspaceDir },
369
+ runtimeProfiles: {
370
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
371
+ },
372
+ internalAgents: {
373
+ defaultRuntime: 'pi-ai.default',
374
+ agents: {
375
+ dreamer: { enabled: false, runtimeProfile: 'pi-ai.default' },
376
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
377
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
378
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.default' },
379
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
380
+ },
381
+ },
382
+ };
383
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
384
+ }
385
+
386
+ describe('handleRunRuleHost — PRI-461 readiness integration', () => {
387
+ let workspaceDir: string;
388
+ let savedEnv: NodeJS.ProcessEnv;
389
+
390
+ beforeEach(() => {
391
+ workspaceDir = mkTmpDir();
392
+ savedEnv = { ...process.env };
393
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key';
394
+ });
395
+
396
+ afterEach(() => {
397
+ process.env = savedEnv;
398
+ try { fs.rmSync(workspaceDir, { recursive: true, force: true }); } catch { /* ignore */ }
399
+ });
400
+
401
+ // ── ready ───────────────────────────────────────────────────────────────
402
+
403
+ it('emits readiness=ready in --json dry-run when all agents and code-rule capability are ON', async () => {
404
+ writeFullReadyConfig(workspaceDir);
405
+ const { stdout, exitCode } = await captureStdio(() =>
406
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
407
+ );
408
+ const payload = parseJsonObject(stdout.trim());
409
+ expect(payload.status).toBe('dry_run');
410
+ expect(payload.readinessStatus).toBe('ready');
411
+ const readiness = payload.readiness;
412
+ expect(isRecord(readiness)).toBe(true);
413
+ if (!isRecord(readiness)) {
414
+ throw new Error('readiness is not a record');
415
+ }
416
+ expect(readiness.status).toBe('ready');
417
+ expect(exitCode).toBeUndefined();
418
+ });
419
+
420
+ it('emits readiness=ready in plain-text dry-run output', async () => {
421
+ writeFullReadyConfig(workspaceDir);
422
+ const { stdout } = await captureStdio(() =>
423
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
424
+ );
425
+ expect(stdout).toMatch(/readiness:\s*READY/i);
426
+ });
427
+
428
+ // ── text_principle_only ─────────────────────────────────────────────────
429
+
430
+ it('emits readiness=text_principle_only in --json dry-run when code_rule_capability is OFF', async () => {
431
+ writeTextPrincipleOnlyConfig(workspaceDir);
432
+ const { stdout, exitCode } = await captureStdio(() =>
433
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
434
+ );
435
+ const payload = parseJsonObject(stdout.trim());
436
+ expect(payload.status).toBe('dry_run');
437
+ expect(payload.readinessStatus).toBe('text_principle_only');
438
+ const readiness = payload.readiness;
439
+ expect(isRecord(readiness)).toBe(true);
440
+ if (!isRecord(readiness)) {
441
+ throw new Error('readiness is not a record');
442
+ }
443
+ expect(readiness.status).toBe('text_principle_only');
444
+ expect(typeof readiness.reason).toBe('string');
445
+ expect(typeof readiness.nextAction).toBe('string');
446
+ expect(exitCode).toBeUndefined();
447
+ });
448
+
449
+ it('does not reclassify evaluator-disabled text_principle_only as runtime resolution failure', async () => {
450
+ writeEvaluatorDisabledConfig(workspaceDir);
451
+ const { stdout, exitCode } = await captureStdio(() =>
452
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
453
+ );
454
+ const payload = parseJsonObject(stdout.trim());
455
+ expect(payload.status).toBe('dry_run');
456
+ expect(payload.readinessStatus).toBe('text_principle_only');
457
+ const readiness = payload.readiness;
458
+ expect(isRecord(readiness)).toBe(true);
459
+ if (!isRecord(readiness)) {
460
+ throw new Error('readiness is not a record');
461
+ }
462
+ expect(readiness.status).toBe('text_principle_only');
463
+ expect(String(readiness.reason)).toContain('evaluator');
464
+ expect(String(payload.capabilityStatus)).toContain('evaluator');
465
+ expect(exitCode).toBeUndefined();
466
+ });
467
+
468
+ it('emits readiness=text_principle_only in plain-text dry-run output', async () => {
469
+ writeTextPrincipleOnlyConfig(workspaceDir);
470
+ const { stdout } = await captureStdio(() =>
471
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
472
+ );
473
+ expect(stdout).toMatch(/readiness:\s*TEXT_PRINCIPLE_ONLY/i);
474
+ });
475
+
476
+ // ── refused ─────────────────────────────────────────────────────────────
477
+
478
+ it('exits with code=1 and emits status=refused in --json when dreamer is disabled', async () => {
479
+ writeRefusedConfig(workspaceDir);
480
+ const { stdout, exitCode } = await captureStdio(() =>
481
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
482
+ );
483
+ const payload = parseJsonObject(stdout.trim());
484
+ expect(payload.status).toBe('refused');
485
+ expect(typeof payload.reason).toBe('string');
486
+ expect(typeof payload.nextAction).toBe('string');
487
+ expect(exitCode).toBe(1);
488
+ });
489
+
490
+ it('exits with code=1 and emits REFUSED in plain-text when dreamer is disabled', async () => {
491
+ writeRefusedConfig(workspaceDir);
492
+ const { stderr, exitCode } = await captureStdio(() =>
493
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
494
+ );
495
+ expect(stderr).toMatch(/REFUSED/i);
496
+ expect(stderr).toMatch(/dreamer/i);
497
+ expect(exitCode).toBe(1);
498
+ });
499
+
500
+ it('refused status includes the full readiness object in --json output', async () => {
501
+ writeRefusedConfig(workspaceDir);
502
+ const { stdout } = await captureStdio(() =>
503
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
504
+ );
505
+ const payload = parseJsonObject(stdout.trim());
506
+ expect(payload.readiness).toBeDefined();
507
+ const readiness = payload.readiness;
508
+ expect(isRecord(readiness)).toBe(true);
509
+ if (!isRecord(readiness)) {
510
+ throw new Error('readiness is not a record');
511
+ }
512
+ expect(readiness.status).toBe('refused');
513
+ expect(readiness.agentStatuses).toBeDefined();
514
+ });
515
+
516
+ it('refused status does NOT attempt pipeline execution or adapter construction', async () => {
517
+ // If the handler tried to construct adapters with a disabled dreamer,
518
+ // resolveRunRuleHostRuntime would throw. The readiness gate must prevent
519
+ // that by exiting before resolveRunRuleHostRuntime is called.
520
+ writeRefusedConfig(workspaceDir);
521
+ const { stdout, exitCode } = await captureStdio(() =>
522
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
523
+ );
524
+ const payload = parseJsonObject(stdout.trim());
525
+ // Must be 'refused', NOT 'failed' with 'agent_runtime_resolution_failed'
526
+ expect(payload.status).toBe('refused');
527
+ expect(payload.reason).not.toMatch(/agent_runtime_resolution_failed/);
528
+ expect(exitCode).toBe(1);
529
+ });
530
+ });
@@ -46,8 +46,8 @@ describe('CLI command tree: pd runtime internalization', () => {
46
46
  expect(output).toContain('--json');
47
47
  });
48
48
 
49
- it('runtime subcommand list includes internalization', () => {
49
+ it('runtime subcommand list does NOT include internalization (PRI-455: hidden from --help)', () => {
50
50
  const output = runPdHelp(['runtime', '--help']);
51
- expect(output).toMatch(/internalization\s/);
51
+ expect(output).not.toMatch(/internalization\s/);
52
52
  });
53
53
  });