@principles/pd-cli 1.113.0 → 1.115.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.
@@ -42,6 +42,8 @@ import {
42
42
  runAdversarialLoop,
43
43
  evaluateInRefinerSandbox,
44
44
  DEFAULT_MAX_ROUNDS,
45
+ SqliteApprovalQueueStore,
46
+ getChannelRiskLevel,
45
47
  } from '@principles/core/runtime-v2';
46
48
  import type {
47
49
  AdversarialLoopResult,
@@ -49,6 +51,7 @@ import type {
49
51
  PeerRunnerResult,
50
52
  RefinerRuleHostGateDeps,
51
53
  PIArtifactStore,
54
+ ApprovalRecord,
52
55
  } from '@principles/core/runtime-v2';
53
56
  /* eslint-disable @typescript-eslint/no-use-before-define -- helpers declared after main, matching codebase convention */
54
57
  import { compileDemoRule } from './demo-rule-compiler.js';
@@ -168,6 +171,14 @@ export interface RuleHostPipelineResult {
168
171
  readonly ruleArtifactId: string | null;
169
172
  /** Principle artifact ID (always present when scribe ran). */
170
173
  readonly principleArtifactId: string | null;
174
+ /**
175
+ * Approval ID when the candidate was auto-enqueued into the ApprovalQueue.
176
+ * Present when decision='candidate_ready_for_owner_review' and the pipeline
177
+ * successfully enqueued the candidate for owner review (P1 #1 fix).
178
+ * Null when the candidate was not enqueued (text_principle_only, rejected,
179
+ * or enqueue failed — check degradationReason for details).
180
+ */
181
+ readonly approvalId: string | null;
171
182
  /** Structured reason when decision is not candidate_ready_for_owner_review. */
172
183
  readonly degradationReason?: string;
173
184
  }
@@ -366,6 +377,44 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
366
377
  ? 'candidate_ready_for_owner_review' as const
367
378
  : 'generation_rejected' as const;
368
379
 
380
+ // P1 #1 fix: auto-enqueue the candidate into the ApprovalQueue so the
381
+ // owner can review it. Without this, the pipeline produces a candidate
382
+ // artifact but it never enters the approval queue — the production chain
383
+ // is broken at step 2→3. Tests manually called enqueue(); production did not.
384
+ let approvalId: string | null = null;
385
+ if (pipelineDecision === 'candidate_ready_for_owner_review' && loopResult.ruleArtifactId) {
386
+ try {
387
+ const approvalStore = new SqliteApprovalQueueStore(stateManager.connection);
388
+ const riskLevel = getChannelRiskLevel(channel);
389
+ const enqueuedRecord: ApprovalRecord = await approvalStore.enqueue({
390
+ artifactId: loopResult.ruleArtifactId,
391
+ channel,
392
+ riskLevel,
393
+ summary: `RuleHost pipeline candidate for pain ${opts.painId}`,
394
+ triggerReason: `adversarial_loop_approved: pain=${opts.painId}, rule=${loopResult.ruleArtifactId}`,
395
+ }, new Date().toISOString());
396
+ const { approvalId: enqueuedApprovalId } = enqueuedRecord;
397
+ approvalId = enqueuedApprovalId;
398
+ onProgress('adversarial_loop', 'succeeded', `auto-enqueued as approval ${approvalId}`);
399
+ } catch (err: unknown) {
400
+ // Enqueue failed — the candidate artifact exists but is not in the
401
+ // approval queue. Degrade gracefully with a structured reason (ERR-002).
402
+ const enqueueErr = err instanceof Error ? err.message : String(err);
403
+ const degradeReason = `candidate_approved_but_enqueue_failed: ${enqueueErr}. Manual enqueue required: pd runtime activation dispatch --artifact-id ${loopResult.ruleArtifactId} --channel ${channel}`;
404
+ return {
405
+ decision: pipelineDecision,
406
+ painId: opts.painId,
407
+ stages,
408
+ scribeTaskId,
409
+ adversarialLoop: loopResult,
410
+ ruleArtifactId: loopResult.ruleArtifactId,
411
+ principleArtifactId: loopResult.principleArtifactId,
412
+ approvalId: null,
413
+ degradationReason: degradeReason,
414
+ };
415
+ }
416
+ }
417
+
369
418
  return {
370
419
  decision: pipelineDecision,
371
420
  painId: opts.painId,
@@ -374,6 +423,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
374
423
  adversarialLoop: loopResult,
375
424
  ruleArtifactId: loopResult.ruleArtifactId,
376
425
  principleArtifactId: loopResult.principleArtifactId,
426
+ approvalId,
377
427
  degradationReason: loopResult.degradationReason,
378
428
  };
379
429
  } finally {
@@ -533,6 +583,7 @@ function rejectedResult(painId: string, stages: RuleHostPipelineStage[], degrada
533
583
  scribeTaskId: null,
534
584
  ruleArtifactId: null,
535
585
  principleArtifactId: null,
586
+ approvalId: null,
536
587
  degradationReason,
537
588
  };
538
589
  }
@@ -567,6 +618,7 @@ async function textPrincipleOnlyResult(
567
618
  scribeTaskId,
568
619
  ruleArtifactId: null,
569
620
  principleArtifactId: principleArt?.artifactId ?? null,
621
+ approvalId: null,
570
622
  degradationReason: `code_rule_capability_off: ${disabledReason}`,
571
623
  };
572
624
  } catch (error: unknown) {
@@ -578,6 +630,7 @@ async function textPrincipleOnlyResult(
578
630
  scribeTaskId,
579
631
  ruleArtifactId: null,
580
632
  principleArtifactId: null,
633
+ approvalId: null,
581
634
  degradationReason: `code_rule_capability_off: ${disabledReason}; principle_artifact_lookup_failed: ${message}`,
582
635
  };
583
636
  }
@@ -62,4 +62,18 @@ describe('CLI command tree structure', () => {
62
62
  const output = runPdHelp(['runtime', '--help']);
63
63
  expect(output).toMatch(/health\s/);
64
64
  });
65
+
66
+ it('activation edit command exists under runtime activation (pd runtime activation edit --help)', () => {
67
+ const output = runPdHelp(['runtime', 'activation', 'edit', '--help']);
68
+ expect(output).toContain('--approval-id');
69
+ expect(output).toContain('--new-artifact-id');
70
+ expect(output).toContain('--edit-reason');
71
+ expect(output).toContain('--workspace');
72
+ expect(output).toContain('--json');
73
+ });
74
+
75
+ it('activation subcommand list includes edit (pd runtime activation --help)', () => {
76
+ const output = runPdHelp(['runtime', 'activation', '--help']);
77
+ expect(output).toMatch(/edit\s/);
78
+ });
65
79
  });
@@ -0,0 +1,253 @@
1
+ /**
2
+ * handleRunRuleHost behaviour tests (PRI-429) — input validation gates,
3
+ * dry-run output, and capability branches.
4
+ *
5
+ * The existing flag-wiring test only proves Commander parses the flags.
6
+ * This file verifies the handler's behavioural gates:
7
+ * - mutual exclusivity of --dry-run and --confirm
8
+ * - required pain-id (and whitespace-only forms)
9
+ * - channel allowlist enforcement
10
+ * - --max-rounds / --timeout-ms positive-integer validation
11
+ * - default dry-run output (both plain-text and JSON)
12
+ * - --json output parses as a single JSON object
13
+ *
14
+ * These gates are the CLI-gate contract for run-rulehost. If any of them
15
+ * regress, operators silently get undefined behaviour instead of a clear
16
+ * error + next-action recommendation.
17
+ */
18
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as os from 'node:os';
22
+ import * as yaml from 'js-yaml';
23
+ import { handleRunRuleHost } from '../../src/commands/runtime-internalization-run-rulehost.js';
24
+
25
+ // ── workspace helpers ─────────────────────────────────────────────────────
26
+
27
+ function mkTmpDir(): string {
28
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-rulehost-handler-'));
29
+ }
30
+
31
+ function writeMinimalValidConfig(workspaceDir: string): void {
32
+ const configDir = path.join(workspaceDir, '.pd');
33
+ fs.mkdirSync(configDir, { recursive: true });
34
+ const cfg = {
35
+ version: 1,
36
+ features: {
37
+ prompt: { category: 'core', enabled: true },
38
+ code_tool_hook: { category: 'core', enabled: true },
39
+ defer_archive: { category: 'core', enabled: true },
40
+ },
41
+ workspace: { default: workspaceDir },
42
+ runtimeProfiles: {
43
+ 'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
44
+ },
45
+ internalAgents: {
46
+ defaultRuntime: 'pi-ai.default',
47
+ agents: {
48
+ dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
49
+ philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
50
+ scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
51
+ evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
52
+ },
53
+ },
54
+ };
55
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
56
+ }
57
+
58
+ // ── stdout/stderr capture helpers ──────────────────────────────────────
59
+
60
+ interface StdIoState {
61
+ stdout: string;
62
+ stderr: string;
63
+ exitCode: number | undefined;
64
+ }
65
+
66
+ function parseJsonObject(text: string): Record<string, unknown> {
67
+ const parsed: unknown = JSON.parse(text);
68
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
69
+ throw new Error('stdout is not a JSON object');
70
+ }
71
+ return parsed;
72
+ }
73
+
74
+ function captureStdio(fn: () => Promise<void>): Promise<StdIoState> {
75
+ return new Promise((resolve, reject) => {
76
+ const origExitCode = process.exitCode;
77
+ process.exitCode = undefined;
78
+ let stdout = '';
79
+ let stderr = '';
80
+ const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
81
+ stdout += typeof chunk === 'string' ? chunk : chunk.toString();
82
+ return true;
83
+ });
84
+ const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => {
85
+ stderr += typeof chunk === 'string' ? chunk : chunk.toString();
86
+ return true;
87
+ });
88
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
89
+ stderr += args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ') + '\n';
90
+ });
91
+ fn()
92
+ .then(() => {
93
+ const captured = { stdout, stderr, exitCode: process.exitCode };
94
+ stdoutSpy.mockRestore();
95
+ stderrSpy.mockRestore();
96
+ consoleErrorSpy.mockRestore();
97
+ process.exitCode = origExitCode;
98
+ resolve(captured);
99
+ })
100
+ .catch((err) => {
101
+ stdoutSpy.mockRestore();
102
+ stderrSpy.mockRestore();
103
+ consoleErrorSpy.mockRestore();
104
+ process.exitCode = origExitCode;
105
+ reject(err);
106
+ });
107
+ });
108
+ }
109
+
110
+ describe('handleRunRuleHost — input validation gates', () => {
111
+ it('sets exitCode=1 and emits an error when both --dry-run and --confirm are passed (plain text)', async () => {
112
+ const { stdout, stderr, exitCode } = await captureStdio(() =>
113
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, confirm: true }),
114
+ );
115
+ expect(exitCode).toBe(1);
116
+ // plain-text mode: error on stderr includes 'mutually exclusive'
117
+ expect(stderr).toMatch(/mutually exclusive/i);
118
+ // stdout must not contain JSON object (handler writes to stderr).
119
+ expect(stdout.trim()).toBe('');
120
+ });
121
+
122
+ it('sets exitCode=1 and emits a JSON object when both --dry-run and --confirm are passed with --json', async () => {
123
+ const { stdout, exitCode } = await captureStdio(() =>
124
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, confirm: true, json: true }),
125
+ );
126
+ expect(exitCode).toBe(1);
127
+ const payload = parseJsonObject(stdout.trim());
128
+ expect(payload.status).toBe('failed');
129
+ expect(payload.nextAction).toBeDefined();
130
+ expect(typeof payload.reason).toBe('string');
131
+ });
132
+
133
+ it('sets exitCode=1 when --pain-id is missing (falsy string)', async () => {
134
+ const { exitCode } = await captureStdio(() => handleRunRuleHost({ painId: '' }));
135
+ expect(exitCode).toBe(1);
136
+ });
137
+
138
+ it('sets exitCode=1 when --pain-id is whitespace-only', async () => {
139
+ const { exitCode } = await captureStdio(() => handleRunRuleHost({ painId: ' \t ' }));
140
+ expect(exitCode).toBe(1);
141
+ });
142
+
143
+ it('sets exitCode=1 for unsupported channels (plain-text mode)', async () => {
144
+ const { exitCode, stderr } = await captureStdio(() =>
145
+ handleRunRuleHost({ painId: 'pain-1', channel: 'wizard' }),
146
+ );
147
+ expect(exitCode).toBe(1);
148
+ expect(stderr).toMatch(/wizard/);
149
+ });
150
+
151
+ it('sets exitCode=1 for unsupported channels (JSON mode)', async () => {
152
+ const { stdout, exitCode } = await captureStdio(() =>
153
+ handleRunRuleHost({ painId: 'pain-1', channel: 'wizard', json: true }),
154
+ );
155
+ expect(exitCode).toBe(1);
156
+ const payload = parseJsonObject(stdout.trim());
157
+ expect(payload.status).toBe('failed');
158
+ expect(payload.reason).toMatch(/wizard/);
159
+ });
160
+
161
+ it('sets exitCode=1 when --max-rounds is zero', async () => {
162
+ const { exitCode } = await captureStdio(() =>
163
+ handleRunRuleHost({ painId: 'pain-1', maxRounds: 0, dryRun: true }));
164
+ expect(exitCode).toBe(1);
165
+ });
166
+
167
+ it('sets exitCode=1 when --max-rounds is negative', async () => {
168
+ const { exitCode } = await captureStdio(() =>
169
+ handleRunRuleHost({ painId: 'pain-1', maxRounds: -5, dryRun: true }));
170
+ expect(exitCode).toBe(1);
171
+ });
172
+
173
+ it('sets exitCode=1 when --timeout-ms is zero', async () => {
174
+ const { exitCode } = await captureStdio(() =>
175
+ handleRunRuleHost({ painId: 'pain-1', timeoutMs: 0, dryRun: true }));
176
+ expect(exitCode).toBe(1);
177
+ });
178
+
179
+ it('sets exitCode=1 when --timeout-ms is negative', async () => {
180
+ const { exitCode } = await captureStdio(() =>
181
+ handleRunRuleHost({ painId: 'pain-1', timeoutMs: -100, dryRun: true }));
182
+ expect(exitCode).toBe(1);
183
+ });
184
+
185
+ it.each([
186
+ ['NaN maxRounds', { maxRounds: Number.NaN }],
187
+ ['fractional maxRounds', { maxRounds: 1.5 }],
188
+ ['NaN timeoutMs', { timeoutMs: Number.NaN }],
189
+ ['fractional timeoutMs', { timeoutMs: 1.5 }],
190
+ ])('sets exitCode=1 for %s', async (_label, invalidOption) => {
191
+ const { exitCode } = await captureStdio(() =>
192
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, ...invalidOption }),
193
+ );
194
+ expect(exitCode).toBe(1);
195
+ });
196
+ });
197
+
198
+ describe('handleRunRuleHost — dry-run mode output shape (with minimal pd-config.yaml', () => {
199
+ let workspaceDir: string;
200
+ let savedEnv: NodeJS.ProcessEnv;
201
+
202
+ beforeEach(() => {
203
+ workspaceDir = mkTmpDir();
204
+ writeMinimalValidConfig(workspaceDir);
205
+ savedEnv = { ...process.env };
206
+ process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key';
207
+ });
208
+
209
+ afterEach(() => {
210
+ process.env = savedEnv;
211
+ try { fs.rmSync(workspaceDir, { recursive: true, force: true }); } catch { /* ignore */ }
212
+ });
213
+
214
+ it('emits a single JSON object in --json dry-run mode', async () => {
215
+ const { stdout, exitCode } = await captureStdio(() =>
216
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
217
+ );
218
+ const payload = parseJsonObject(stdout.trim());
219
+ expect(payload.status).toBe('dry_run');
220
+ expect(typeof payload.capabilityStatus).toBe('string');
221
+ expect(typeof payload.nextAction).toBe('string');
222
+ expect(payload.painId).toBe('pain-1');
223
+ // Must not be error-exit for a valid dry-run.
224
+ expect(exitCode).toBeUndefined();
225
+ });
226
+
227
+ it('does NOT emit JSON on stdout in plain-text dry-run mode', async () => {
228
+ const { stdout } = await captureStdio(() =>
229
+ handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
230
+ );
231
+ expect(stdout).toMatch(/RuleHost Pipeline/);
232
+ // Plain text output should not start with '{'
233
+ expect(() => JSON.parse(stdout.trim())).toThrow();
234
+ });
235
+
236
+ it('defaults to dry-run behaviour when neither --dry-run nor --confirm is passed', async () => {
237
+ const { stdout } = await captureStdio(() =>
238
+ handleRunRuleHost({ painId: 'pain-1', json: true, workspace: workspaceDir }),
239
+ );
240
+ const payload = parseJsonObject(stdout.trim());
241
+ expect(payload.status).toBe('dry_run');
242
+ });
243
+
244
+ it('accepts the three supported channels and passes config', async () => {
245
+ for (const channel of ['prompt', 'code_tool_hook', 'defer_archive'] as const) {
246
+ const { exitCode } = await captureStdio(() =>
247
+ handleRunRuleHost({ painId: 'pain-1', channel, dryRun: true, workspace: workspaceDir }),
248
+ );
249
+ // Channel gate passed — exitCode should not be 1 for supported channels.
250
+ expect(exitCode).toBeUndefined();
251
+ }
252
+ });
253
+ });