@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.
- package/dist/commands/runtime-activation.d.ts +37 -0
- package/dist/commands/runtime-activation.d.ts.map +1 -1
- package/dist/commands/runtime-activation.js +416 -2
- package/dist/commands/runtime-activation.js.map +1 -1
- package/dist/index.js +54 -1
- package/dist/index.js.map +1 -1
- package/dist/services/demo-rule-compiler.d.ts.map +1 -1
- package/dist/services/demo-rule-compiler.js +30 -6
- package/dist/services/demo-rule-compiler.js.map +1 -1
- package/dist/services/rulehost-pipeline-runner.d.ts +8 -0
- package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
- package/dist/services/rulehost-pipeline-runner.js +43 -1
- package/dist/services/rulehost-pipeline-runner.js.map +1 -1
- package/package.json +1 -1
- package/scripts/llm-dogfood.ts +419 -0
- package/src/commands/runtime-activation.ts +459 -1
- package/src/index.ts +57 -1
- package/src/services/demo-rule-compiler.ts +35 -15
- package/src/services/rulehost-pipeline-runner.ts +53 -0
- package/tests/commands/cli-command-tree.test.ts +14 -0
- package/tests/commands/run-rulehost-handler.test.ts +253 -0
- package/tests/commands/runtime-activation.test.ts +553 -1
- package/tests/e2e/cross-package-acceptance.test.ts +549 -0
- package/tests/services/demo-rule-compiler.test.ts +242 -0
- package/tests/services/rulehost-pipeline-runner.test.ts +6 -0
|
@@ -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
|
+
});
|