@principles/pd-cli 1.112.0 → 1.113.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 (26) hide show
  1. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.d.ts +24 -0
  2. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.d.ts.map +1 -0
  3. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js +223 -0
  4. package/dist/commands/__tests__/run-rulehost-flag-wiring.test.js.map +1 -0
  5. package/dist/commands/runtime-internalization-run-rulehost.d.ts +23 -0
  6. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -0
  7. package/dist/commands/runtime-internalization-run-rulehost.js +364 -0
  8. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -0
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/services/demo-rule-compiler.d.ts +24 -0
  12. package/dist/services/demo-rule-compiler.d.ts.map +1 -0
  13. package/dist/services/demo-rule-compiler.js +53 -0
  14. package/dist/services/demo-rule-compiler.js.map +1 -0
  15. package/dist/services/rulehost-pipeline-runner.d.ts +124 -0
  16. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -0
  17. package/dist/services/rulehost-pipeline-runner.js +334 -0
  18. package/dist/services/rulehost-pipeline-runner.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/commands/__tests__/run-rulehost-flag-wiring.test.ts +280 -0
  21. package/src/commands/runtime-internalization-run-rulehost.ts +417 -0
  22. package/src/index.ts +3 -0
  23. package/src/services/demo-rule-compiler.ts +71 -0
  24. package/src/services/rulehost-pipeline-runner.ts +585 -0
  25. package/tests/services/rulehost-pipeline-e2e.test.ts +477 -0
  26. package/tests/services/rulehost-pipeline-runner.test.ts +519 -0
@@ -0,0 +1,477 @@
1
+ /**
2
+ * runRuleHost production-wiring test (PRI-429) — DETERMINISTIC, NO REAL LLM.
3
+ *
4
+ * Replaces the skippable e2e test (G fix). This test verifies the production
5
+ * wiring is real: the CLI handler resolves per-agent config, constructs the
6
+ * ArtificerL2Adapter when both artificer+evaluator are enabled, and passes the
7
+ * CodeRuleCapability to the pipeline. It does NOT require a real LLM — it tests
8
+ * the dry-run path which resolves config + capability status without running
9
+ * the pipeline.
10
+ *
11
+ * Atomic capability contract (per user correction 2026-06-18):
12
+ * - Both artificer AND evaluator must be enabled → capability ON
13
+ * - Either disabled → capability OFF with structured reason
14
+ * - API key missing → capability OFF with structured reason
15
+ *
16
+ * CLI gate compliance:
17
+ * - --json outputs exactly one parseable JSON object
18
+ * - --dry-run is default; --confirm required for mutation
19
+ * - --dry-run and --confirm are mutually exclusive
20
+ * - failure paths include structured reason + nextAction
21
+ *
22
+ * ERR refs considered:
23
+ * - ERR-001: treat parsed JSON as unknown — JSON.parse output is validated
24
+ * - ERR-002: fail loud with reason — all failure paths include reason + nextAction
25
+ * - ERR-009: required fields fail loud — missing painId is rejected
26
+ * - ERR-013: Object.hasOwn for key checks — not relevant here (no untrusted key checks)
27
+ */
28
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
29
+ import * as fs from 'node:fs';
30
+ import * as path from 'node:path';
31
+ import * as os from 'node:os';
32
+ import * as yaml from 'js-yaml';
33
+ import { handleRunRuleHost } from '../../src/commands/runtime-internalization-run-rulehost.js';
34
+
35
+ // ── Helpers ────────────────────────────────────────────────────────────────
36
+
37
+ function mkTmpDir(prefix = 'pd-rulehost-wiring-'): string {
38
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
39
+ }
40
+
41
+ function rmTmpDir(dir: string): void {
42
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
43
+ }
44
+
45
+ function writeConfig(workspaceDir: string, content: object): void {
46
+ const pdDir = path.join(workspaceDir, '.pd');
47
+ fs.mkdirSync(pdDir, { recursive: true });
48
+ fs.writeFileSync(
49
+ path.join(pdDir, 'config.yaml'),
50
+ yaml.dump(content, { lineWidth: -1 }),
51
+ 'utf8',
52
+ );
53
+ }
54
+
55
+ /** Config with both artificer+evaluator enabled, pi-ai profile, API key env set. */
56
+ function makeCapabilityOnConfig(workspaceDir: string): object {
57
+ return {
58
+ version: 1,
59
+ features: {
60
+ prompt: { enabled: true, category: 'core' },
61
+ code_tool_hook: { enabled: true, category: 'core' },
62
+ defer_archive: { enabled: true, category: 'core' },
63
+ code_rule_capability: { enabled: true, category: 'quiet' },
64
+ },
65
+ workspace: { default: workspaceDir },
66
+ runtimeProfiles: {
67
+ 'pi-ai.test': {
68
+ type: 'pi-ai',
69
+ provider: 'openrouter',
70
+ model: 'anthropic/claude-sonnet-4',
71
+ apiKeyEnv: 'TEST_RULEHOST_API_KEY',
72
+ baseUrl: 'https://openrouter.ai/api/v1',
73
+ timeoutMs: 300000,
74
+ maxRetries: 3,
75
+ },
76
+ },
77
+ internalAgents: {
78
+ defaultRuntime: 'pi-ai.test',
79
+ agents: {
80
+ diagnostician: { enabled: true, runtimeProfile: 'pi-ai.test' },
81
+ dreamer: { enabled: true },
82
+ philosopher: { enabled: true },
83
+ scribe: { enabled: true },
84
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.test' },
85
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.test' },
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ /** Config with artificer disabled (capability must be OFF). */
92
+ function makeArtificerDisabledConfig(workspaceDir: string): object {
93
+ const cfg = makeCapabilityOnConfig(workspaceDir) as Record<string, unknown>;
94
+ const internalAgents = cfg.internalAgents as { agents: Record<string, { enabled: boolean; runtimeProfile?: string }> };
95
+ internalAgents.agents.artificer = { enabled: false };
96
+ return cfg;
97
+ }
98
+
99
+ /** Config with evaluator disabled (capability must be OFF). */
100
+ function makeEvaluatorDisabledConfig(workspaceDir: string): object {
101
+ const cfg = makeCapabilityOnConfig(workspaceDir) as Record<string, unknown>;
102
+ const internalAgents = cfg.internalAgents as { agents: Record<string, { enabled: boolean; runtimeProfile?: string }> };
103
+ internalAgents.agents.evaluator = { enabled: false };
104
+ return cfg;
105
+ }
106
+
107
+ // ── Test setup ─────────────────────────────────────────────────────────────
108
+
109
+ describe('runRuleHost production-wiring (PRI-429) — deterministic, no LLM', () => {
110
+ let stdoutSpy: ReturnType<typeof vi.spyOn>;
111
+ let stderrSpy: ReturnType<typeof vi.spyOn>;
112
+ let originalExitCode: number | undefined;
113
+ let originalApiKey: string | undefined;
114
+ let originalBaseKey: string | undefined;
115
+ let originalArtificerKey: string | undefined;
116
+ let tmpDirs: string[];
117
+
118
+ beforeEach(() => {
119
+ stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
120
+ stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
121
+ originalExitCode = process.exitCode;
122
+ process.exitCode = undefined;
123
+ originalApiKey = process.env.TEST_RULEHOST_API_KEY;
124
+ originalBaseKey = process.env.TEST_RULEHOST_BASE_KEY;
125
+ originalArtificerKey = process.env.TEST_RULEHOST_ARTIFICER_KEY;
126
+ tmpDirs = [];
127
+ });
128
+
129
+ afterEach(() => {
130
+ stdoutSpy.mockRestore();
131
+ stderrSpy.mockRestore();
132
+ process.exitCode = originalExitCode;
133
+ if (originalApiKey === undefined) {
134
+ delete process.env.TEST_RULEHOST_API_KEY;
135
+ } else {
136
+ process.env.TEST_RULEHOST_API_KEY = originalApiKey;
137
+ }
138
+ if (originalBaseKey === undefined) {
139
+ delete process.env.TEST_RULEHOST_BASE_KEY;
140
+ } else {
141
+ process.env.TEST_RULEHOST_BASE_KEY = originalBaseKey;
142
+ }
143
+ if (originalArtificerKey === undefined) {
144
+ delete process.env.TEST_RULEHOST_ARTIFICER_KEY;
145
+ } else {
146
+ process.env.TEST_RULEHOST_ARTIFICER_KEY = originalArtificerKey;
147
+ }
148
+ for (const dir of tmpDirs) {
149
+ rmTmpDir(dir);
150
+ }
151
+ });
152
+
153
+ function makeWorkspace(configFactory: (dir: string) => object): string {
154
+ const dir = mkTmpDir();
155
+ tmpDirs.push(dir);
156
+ writeConfig(dir, configFactory(dir));
157
+ return dir;
158
+ }
159
+
160
+ /** Extract the single JSON object written to stdout. Fails if not exactly one write. */
161
+ function parseJsonOutput(): unknown {
162
+ expect(stdoutSpy).toHaveBeenCalledTimes(1);
163
+ const raw = stdoutSpy.mock.calls[0][0] as string;
164
+ return JSON.parse(raw);
165
+ }
166
+
167
+ // ── Capability ON: both agents enabled, API key set ──────────────────────
168
+
169
+ it('dry-run reports code_rule_capability: ON when artificer+evaluator enabled and API key set', async () => {
170
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
171
+ const workspace = makeWorkspace(makeCapabilityOnConfig);
172
+
173
+ await handleRunRuleHost({
174
+ workspace,
175
+ painId: 'pain-wiring-001',
176
+ dryRun: true,
177
+ json: true,
178
+ });
179
+
180
+ const output = parseJsonOutput();
181
+ expect(typeof output).toBe('object');
182
+ expect(output).not.toBeNull();
183
+ const obj = output as { status: string; codeRuleCapability?: { enabled: boolean; disabledReason?: string }; capabilityStatus?: string };
184
+ expect(obj.status).toBe('dry_run');
185
+ expect(obj.codeRuleCapability).toBeDefined();
186
+ expect(obj.codeRuleCapability?.enabled).toBe(true);
187
+ expect(obj.codeRuleCapability?.disabledReason).toBeUndefined();
188
+ expect(obj.capabilityStatus).toContain('ON');
189
+ // exitCode must NOT be set for dry_run success
190
+ expect(process.exitCode).toBeUndefined();
191
+ });
192
+
193
+ it('keeps code_rule_capability OFF when the quiet feature flag is omitted', async () => {
194
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
195
+ const workspace = makeWorkspace((dir) => {
196
+ const config = makeCapabilityOnConfig(dir);
197
+ const features = Reflect.get(config, 'features');
198
+ if (features === null || typeof features !== 'object') throw new Error('features fixture missing');
199
+ Reflect.deleteProperty(features, 'code_rule_capability');
200
+ return config;
201
+ });
202
+
203
+ await handleRunRuleHost({ workspace, painId: 'pain-flag-off', dryRun: true, json: true });
204
+
205
+ const output = parseJsonOutput();
206
+ expect(output.status).toBe('dry_run');
207
+ expect(output.codeRuleCapability).toEqual(expect.objectContaining({ enabled: false }));
208
+ expect(String(output.codeRuleCapability?.disabledReason)).toContain('feature flag');
209
+ });
210
+
211
+ it('reports the resolved runtime profile for every executed agent', async () => {
212
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
213
+ const workspace = makeWorkspace(makeCapabilityOnConfig);
214
+
215
+ await handleRunRuleHost({ workspace, painId: 'pain-profiles', dryRun: true, json: true });
216
+
217
+ const output = parseJsonOutput();
218
+ expect(output.agentRuntimeProfiles).toEqual({
219
+ dreamer: 'pi-ai.test',
220
+ philosopher: 'pi-ai.test',
221
+ scribe: 'pi-ai.test',
222
+ artificer: 'pi-ai.test',
223
+ evaluator: 'pi-ai.test',
224
+ });
225
+ });
226
+
227
+ it('fails loud before mutation when philosopher is disabled', async () => {
228
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
229
+ const workspace = makeWorkspace((dir) => {
230
+ const config = makeCapabilityOnConfig(dir);
231
+ const internalAgents = Reflect.get(config, 'internalAgents');
232
+ if (internalAgents === null || typeof internalAgents !== 'object') throw new Error('internalAgents fixture missing');
233
+ const agents = Reflect.get(internalAgents, 'agents');
234
+ if (agents === null || typeof agents !== 'object') throw new Error('agents fixture missing');
235
+ Reflect.set(agents, 'philosopher', { enabled: false });
236
+ return config;
237
+ });
238
+
239
+ await handleRunRuleHost({ workspace, painId: 'pain-disabled-philosopher', confirm: true, json: true });
240
+
241
+ const output = parseJsonOutput();
242
+ expect(output.status).toBe('failed');
243
+ expect(output.reason).toBe('agent_runtime_resolution_failed');
244
+ expect(String(output.message)).toContain('philosopher');
245
+ expect(process.exitCode).toBe(1);
246
+ expect(fs.existsSync(path.join(workspace, '.state', 'runtime-v2.sqlite'))).toBe(false);
247
+ });
248
+
249
+ it('rejects fractional numeric handler options as non-integers', async () => {
250
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
251
+ await handleRunRuleHost({
252
+ workspace: makeWorkspace(makeCapabilityOnConfig),
253
+ painId: 'pain-fractional',
254
+ maxRounds: 1.5,
255
+ dryRun: true,
256
+ json: true,
257
+ });
258
+
259
+ const output = parseJsonOutput();
260
+ expect(output.status).toBe('failed');
261
+ expect(String(output.reason)).toContain('invalid --max-rounds');
262
+ });
263
+
264
+ // ── Capability OFF: artificer disabled ───────────────────────────────────
265
+
266
+ it('dry-run reports code_rule_capability: OFF when artificer disabled', async () => {
267
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
268
+ const workspace = makeWorkspace(makeArtificerDisabledConfig);
269
+
270
+ await handleRunRuleHost({
271
+ workspace,
272
+ painId: 'pain-wiring-002',
273
+ dryRun: true,
274
+ json: true,
275
+ });
276
+
277
+ const output = parseJsonOutput();
278
+ const obj = output as { status: string; codeRuleCapability: { enabled: boolean; disabledReason?: string } };
279
+ expect(obj.status).toBe('dry_run');
280
+ expect(obj.codeRuleCapability.enabled).toBe(false);
281
+ expect(obj.codeRuleCapability.disabledReason).toContain('artificer');
282
+ });
283
+
284
+ // ── Capability OFF: evaluator disabled ───────────────────────────────────
285
+
286
+ it('dry-run reports code_rule_capability: OFF when evaluator disabled', async () => {
287
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
288
+ const workspace = makeWorkspace(makeEvaluatorDisabledConfig);
289
+
290
+ await handleRunRuleHost({
291
+ workspace,
292
+ painId: 'pain-wiring-003',
293
+ dryRun: true,
294
+ json: true,
295
+ });
296
+
297
+ const output = parseJsonOutput();
298
+ const obj = output as { status: string; codeRuleCapability: { enabled: boolean; disabledReason?: string } };
299
+ expect(obj.status).toBe('dry_run');
300
+ expect(obj.codeRuleCapability.enabled).toBe(false);
301
+ expect(obj.codeRuleCapability.disabledReason).toContain('evaluator');
302
+ });
303
+
304
+ // ── Capability OFF: API key not set ──────────────────────────────────────
305
+
306
+ it('dry-run reports code_rule_capability: OFF when artificer API key env var is not set', async () => {
307
+ // Base adapter uses BASE_API_KEY (set); artificer uses ARTIFICER_API_KEY (NOT set).
308
+ // This isolates the capability check from the base adapter resolution.
309
+ process.env.TEST_RULEHOST_BASE_KEY = 'sk-base-key-12345';
310
+ delete process.env.TEST_RULEHOST_ARTIFICER_KEY;
311
+ const dir = mkTmpDir();
312
+ tmpDirs.push(dir);
313
+ writeConfig(dir, {
314
+ version: 1,
315
+ features: {
316
+ prompt: { enabled: true, category: 'core' },
317
+ code_tool_hook: { enabled: true, category: 'core' },
318
+ defer_archive: { enabled: true, category: 'core' },
319
+ code_rule_capability: { enabled: true, category: 'quiet' },
320
+ },
321
+ workspace: { default: dir },
322
+ runtimeProfiles: {
323
+ 'pi-ai.base': {
324
+ type: 'pi-ai',
325
+ provider: 'openrouter',
326
+ model: 'anthropic/claude-sonnet-4',
327
+ apiKeyEnv: 'TEST_RULEHOST_BASE_KEY',
328
+ baseUrl: 'https://openrouter.ai/api/v1',
329
+ timeoutMs: 300000,
330
+ maxRetries: 3,
331
+ },
332
+ 'pi-ai.artificer': {
333
+ type: 'pi-ai',
334
+ provider: 'openrouter',
335
+ model: 'anthropic/claude-sonnet-4',
336
+ apiKeyEnv: 'TEST_RULEHOST_ARTIFICER_KEY',
337
+ baseUrl: 'https://openrouter.ai/api/v1',
338
+ timeoutMs: 300000,
339
+ maxRetries: 3,
340
+ },
341
+ },
342
+ internalAgents: {
343
+ defaultRuntime: 'pi-ai.base',
344
+ agents: {
345
+ diagnostician: { enabled: true, runtimeProfile: 'pi-ai.base' },
346
+ dreamer: { enabled: true },
347
+ philosopher: { enabled: true },
348
+ scribe: { enabled: true },
349
+ artificer: { enabled: true, runtimeProfile: 'pi-ai.artificer' },
350
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.base' },
351
+ },
352
+ },
353
+ });
354
+
355
+ await handleRunRuleHost({
356
+ workspace: dir,
357
+ painId: 'pain-wiring-004',
358
+ dryRun: true,
359
+ json: true,
360
+ });
361
+
362
+ const output = parseJsonOutput();
363
+ const obj = output as { status: string; codeRuleCapability: { enabled: boolean; disabledReason?: string } };
364
+ expect(obj.status).toBe('dry_run');
365
+ expect(obj.codeRuleCapability.enabled).toBe(false);
366
+ expect(obj.codeRuleCapability.disabledReason).toContain('apiKeyEnv');
367
+ });
368
+
369
+ // ── CLI gate: mutual exclusivity ─────────────────────────────────────────
370
+
371
+ it('rejects --dry-run and --confirm together with structured reason', async () => {
372
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
373
+ const workspace = makeWorkspace(makeCapabilityOnConfig);
374
+
375
+ await handleRunRuleHost({
376
+ workspace,
377
+ painId: 'pain-wiring-005',
378
+ dryRun: true,
379
+ confirm: true,
380
+ json: true,
381
+ });
382
+
383
+ const output = parseJsonOutput();
384
+ const obj = output as { status: string; reason: string; nextAction: string };
385
+ expect(obj.status).toBe('failed');
386
+ expect(obj.reason).toContain('mutually exclusive');
387
+ expect(obj.nextAction).toBeTruthy();
388
+ expect(process.exitCode).toBe(1);
389
+ });
390
+
391
+ // ── CLI gate: missing painId ─────────────────────────────────────────────
392
+
393
+ it('rejects missing painId with structured reason', async () => {
394
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
395
+ const workspace = makeWorkspace(makeCapabilityOnConfig);
396
+
397
+ await handleRunRuleHost({
398
+ workspace,
399
+ painId: '',
400
+ dryRun: true,
401
+ json: true,
402
+ });
403
+
404
+ const output = parseJsonOutput();
405
+ const obj = output as { status: string; reason: string; nextAction: string };
406
+ expect(obj.status).toBe('failed');
407
+ expect(obj.reason).toContain('painId');
408
+ expect(obj.nextAction).toBeTruthy();
409
+ expect(process.exitCode).toBe(1);
410
+ });
411
+
412
+ // ── CLI gate: JSON output purity ─────────────────────────────────────────
413
+
414
+ it('--json dry-run outputs exactly one parseable JSON object (no banners, no extra lines)', async () => {
415
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
416
+ const workspace = makeWorkspace(makeCapabilityOnConfig);
417
+
418
+ await handleRunRuleHost({
419
+ workspace,
420
+ painId: 'pain-wiring-006',
421
+ dryRun: true,
422
+ json: true,
423
+ });
424
+
425
+ // Exactly one write call to stdout
426
+ expect(stdoutSpy).toHaveBeenCalledTimes(1);
427
+ const raw = stdoutSpy.mock.calls[0][0] as string;
428
+ // Must parse as a single JSON object
429
+ expect(() => JSON.parse(raw)).not.toThrow();
430
+ const parsed = JSON.parse(raw);
431
+ expect(typeof parsed).toBe('object');
432
+ expect(parsed).not.toBeNull();
433
+ expect(Array.isArray(parsed)).toBe(false);
434
+ });
435
+
436
+ // ── CLI gate: default is dry-run (no --confirm = dry-run) ────────────────
437
+
438
+ it('defaults to dry-run when neither --dry-run nor --confirm is passed', async () => {
439
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
440
+ const workspace = makeWorkspace(makeCapabilityOnConfig);
441
+
442
+ await handleRunRuleHost({
443
+ workspace,
444
+ painId: 'pain-wiring-007',
445
+ json: true,
446
+ // Neither dryRun nor confirm — should default to dry-run
447
+ });
448
+
449
+ const output = parseJsonOutput();
450
+ const obj = output as { status: string };
451
+ expect(obj.status).toBe('dry_run');
452
+ // Must NOT set exitCode for dry_run
453
+ expect(process.exitCode).toBeUndefined();
454
+ });
455
+
456
+ // ── CLI gate: unsupported channel ────────────────────────────────────────
457
+
458
+ it('rejects unsupported channel with structured reason', async () => {
459
+ process.env.TEST_RULEHOST_API_KEY = 'sk-test-key-12345';
460
+ const workspace = makeWorkspace(makeCapabilityOnConfig);
461
+
462
+ await handleRunRuleHost({
463
+ workspace,
464
+ painId: 'pain-wiring-008',
465
+ channel: 'invalid_channel',
466
+ dryRun: true,
467
+ json: true,
468
+ });
469
+
470
+ const output = parseJsonOutput();
471
+ const obj = output as { status: string; reason: string; nextAction: string };
472
+ expect(obj.status).toBe('failed');
473
+ expect(obj.reason).toContain('unsupported channel');
474
+ expect(obj.nextAction).toBeTruthy();
475
+ expect(process.exitCode).toBe(1);
476
+ });
477
+ });