@principles/pd-cli 1.118.0 → 1.120.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/__tests__/legacy-cleanup.test.d.ts +18 -0
- package/dist/commands/__tests__/legacy-cleanup.test.d.ts.map +1 -0
- package/dist/commands/__tests__/legacy-cleanup.test.js +459 -0
- package/dist/commands/__tests__/legacy-cleanup.test.js.map +1 -0
- package/dist/commands/__tests__/rulecode-flag-wiring.test.d.ts +21 -0
- package/dist/commands/__tests__/rulecode-flag-wiring.test.d.ts.map +1 -0
- package/dist/commands/__tests__/rulecode-flag-wiring.test.js +179 -0
- package/dist/commands/__tests__/rulecode-flag-wiring.test.js.map +1 -0
- package/dist/commands/__tests__/rulecode-handler.test.d.ts +16 -0
- package/dist/commands/__tests__/rulecode-handler.test.d.ts.map +1 -0
- package/dist/commands/__tests__/rulecode-handler.test.js +285 -0
- package/dist/commands/__tests__/rulecode-handler.test.js.map +1 -0
- package/dist/commands/candidate.d.ts +1 -0
- package/dist/commands/candidate.d.ts.map +1 -1
- package/dist/commands/candidate.js +32 -6
- package/dist/commands/candidate.js.map +1 -1
- package/dist/commands/legacy-cleanup.d.ts +72 -6
- package/dist/commands/legacy-cleanup.d.ts.map +1 -1
- package/dist/commands/legacy-cleanup.js +243 -23
- package/dist/commands/legacy-cleanup.js.map +1 -1
- package/dist/commands/rulecode.d.ts +85 -0
- package/dist/commands/rulecode.d.ts.map +1 -0
- package/dist/commands/rulecode.js +356 -0
- package/dist/commands/rulecode.js.map +1 -0
- package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-rulehost.js +4 -7
- package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
- package/dist/index.js +30 -9
- package/dist/index.js.map +1 -1
- package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
- package/dist/services/rulehost-pipeline-runner.js +31 -15
- package/dist/services/rulehost-pipeline-runner.js.map +1 -1
- package/package.json +1 -1
- package/scripts/llm-dogfood.ts +8 -12
- package/src/commands/__tests__/legacy-cleanup.test.ts +596 -0
- package/src/commands/__tests__/rulecode-flag-wiring.test.ts +230 -0
- package/src/commands/__tests__/rulecode-handler.test.ts +369 -0
- package/src/commands/candidate.ts +29 -7
- package/src/commands/legacy-cleanup.ts +335 -27
- package/src/commands/rulecode.ts +434 -0
- package/src/commands/runtime-internalization-run-rulehost.ts +3 -8
- package/src/index.ts +31 -9
- package/src/services/rulehost-pipeline-runner.ts +36 -18
- package/tests/commands/candidate-internalize-lineage.test.ts +44 -0
- package/tests/commands/cli-command-tree.test.ts +40 -0
- package/tests/commands/runtime.test.ts +9 -3
- package/tests/e2e/cross-package-acceptance.test.ts +1 -1
- package/tests/services/rulehost-pipeline-runner.test.ts +86 -2
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd rulecode — RuleCode dialect spec, static validation, and sandbox replay.
|
|
3
|
+
*
|
|
4
|
+
* PRI-439 Phase 5: Read-only CLI commands that mirror 3 of the 4 Artificer L2
|
|
5
|
+
* agent tools (read_rulecode_spec, validate_rulecode, replay_rulecode). These
|
|
6
|
+
* commands let operators inspect the RuleCode contract and dry-run code
|
|
7
|
+
* against the same pure-logic validators the Artificer L2 agent uses.
|
|
8
|
+
*
|
|
9
|
+
* Subcommands:
|
|
10
|
+
* - spec : print the RuleCode dialect spec text
|
|
11
|
+
* - validate : run static validation (forbidden patterns + return shape +
|
|
12
|
+
* matched=false decision check) on a code string
|
|
13
|
+
* - replay : run sandbox replay of code against a golden trace JSON file
|
|
14
|
+
*
|
|
15
|
+
* All three are READ-ONLY: no DB mutation, no artifact writes, no approvals,
|
|
16
|
+
* no activations. Failure paths include structured reason + nextAction
|
|
17
|
+
* (CLI Operator Gate rule 6).
|
|
18
|
+
*
|
|
19
|
+
* JSON mode is strict: --json outputs exactly one parseable JSON object on
|
|
20
|
+
* stdout, no banners (CLI Operator Gate rule 1).
|
|
21
|
+
*
|
|
22
|
+
* ERR refs:
|
|
23
|
+
* - ERR-001 (no any): all types explicit; untrusted JSON parsed as unknown
|
|
24
|
+
* - ERR-005 (no as bypass): golden trace cases validated with type guards
|
|
25
|
+
* - ERR-009 (fail loud): missing --code/--code-file fails with reason
|
|
26
|
+
* - ERR-002 (graceful degradation with reason): all failure paths include
|
|
27
|
+
* reason + nextAction
|
|
28
|
+
* - ERR-014 (bounded preview): safeStringifyPreview on unknown payloads
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import * as fs from 'node:fs';
|
|
33
|
+
import type { Command } from 'commander';
|
|
34
|
+
import {
|
|
35
|
+
RULECODE_SPEC_TEXT,
|
|
36
|
+
checkForbiddenPatterns,
|
|
37
|
+
checkReturnStatementsMissingFields,
|
|
38
|
+
checkMatchedFalseDecisions,
|
|
39
|
+
evaluateRefinerRuleHostGate,
|
|
40
|
+
buildGoldenTraceFromArtificer,
|
|
41
|
+
createProductionGateDeps,
|
|
42
|
+
} from '@principles/core/runtime-v2';
|
|
43
|
+
import type { GoldenTraceCaseInput } from '@principles/core/runtime-v2';
|
|
44
|
+
import { emitResult } from '../services/cli-output.js';
|
|
45
|
+
|
|
46
|
+
// ── Output types ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface RulecodeSpecOutput {
|
|
49
|
+
status: 'ok';
|
|
50
|
+
spec: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RulecodeValidateOutput {
|
|
54
|
+
status: 'ok' | 'failed';
|
|
55
|
+
valid: boolean;
|
|
56
|
+
violationCount: number;
|
|
57
|
+
violations: string[];
|
|
58
|
+
reason?: string;
|
|
59
|
+
nextAction?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RulecodeReplayOutput {
|
|
63
|
+
status: 'ok' | 'failed';
|
|
64
|
+
decision: string;
|
|
65
|
+
reasons: string[];
|
|
66
|
+
failedCases: { caseId: string; errorType: string; message: string }[];
|
|
67
|
+
forbiddenPatternViolations: string[];
|
|
68
|
+
reason?: string;
|
|
69
|
+
nextAction?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve code from --code (inline) or --code-file (path). Fails loud if
|
|
76
|
+
* neither is provided or the file cannot be read.
|
|
77
|
+
*/
|
|
78
|
+
function resolveCode(opts: { code?: string; codeFile?: string }): { code?: string; error?: { reason: string; nextAction: string } } {
|
|
79
|
+
if (opts.code && opts.code.trim().length > 0) {
|
|
80
|
+
return { code: opts.code };
|
|
81
|
+
}
|
|
82
|
+
if (opts.codeFile) {
|
|
83
|
+
try {
|
|
84
|
+
const resolved = path.resolve(opts.codeFile);
|
|
85
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
86
|
+
if (content.trim().length === 0) {
|
|
87
|
+
return { error: { reason: `code file is empty: ${resolved}`, nextAction: 'provide a non-empty rule implementation file' } };
|
|
88
|
+
}
|
|
89
|
+
return { code: content };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
92
|
+
return { error: { reason: `cannot read --code-file: ${reason}`, nextAction: 'verify the file path exists and is readable' } };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { error: { reason: 'no code provided: pass --code <string> or --code-file <path>', nextAction: 'specify one of --code or --code-file' } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Type guard: validate that an unknown value is a GoldenTraceCaseInput.
|
|
100
|
+
* Treats parsed JSON as untrusted (Runtime Contract Rule 1/2/4).
|
|
101
|
+
*/
|
|
102
|
+
function isGoldenTraceCaseInput(value: unknown): value is GoldenTraceCaseInput {
|
|
103
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
104
|
+
const v = value as Record<string, unknown>;
|
|
105
|
+
if (typeof v.caseId !== 'string' || v.caseId.length === 0) return false;
|
|
106
|
+
if (v.kind !== 'positive' && v.kind !== 'negative') return false;
|
|
107
|
+
if (typeof v.toolName !== 'string' || v.toolName.length === 0) return false;
|
|
108
|
+
if (typeof v.params !== 'object' || v.params === null) return false;
|
|
109
|
+
if (typeof v.expectedDecision !== 'string') return false;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load and validate golden trace cases from a JSON file.
|
|
115
|
+
* Returns either the validated cases or a structured error.
|
|
116
|
+
*/
|
|
117
|
+
function loadGoldenTraceCases(filePath: string): { cases?: GoldenTraceCaseInput[]; error?: { reason: string; nextAction: string } } {
|
|
118
|
+
let raw: string;
|
|
119
|
+
try {
|
|
120
|
+
const resolved = path.resolve(filePath);
|
|
121
|
+
raw = fs.readFileSync(resolved, 'utf8');
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
124
|
+
return { error: { reason: `cannot read --golden-trace file: ${reason}`, nextAction: 'verify the file path exists and is readable' } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(raw);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
132
|
+
return { error: { reason: `golden trace file is not valid JSON: ${reason}`, nextAction: 'fix the JSON syntax in the golden trace file' } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!Array.isArray(parsed)) {
|
|
136
|
+
return { error: { reason: 'golden trace file must contain a JSON array of cases', nextAction: 'provide an array of GoldenTraceCaseInput objects' } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate each element (Runtime Contract Rule 4 — validate array element types)
|
|
140
|
+
const cases: GoldenTraceCaseInput[] = [];
|
|
141
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
142
|
+
const element = parsed[i];
|
|
143
|
+
if (!isGoldenTraceCaseInput(element)) {
|
|
144
|
+
return { error: { reason: `golden trace case at index ${i} is malformed (requires caseId, kind, toolName, params, expectedDecision)`, nextAction: `fix case at index ${i} in the golden trace file` } };
|
|
145
|
+
}
|
|
146
|
+
cases.push(element);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (cases.length < 2) {
|
|
150
|
+
return { error: { reason: `golden trace must contain at least 2 cases (1 positive + 1 negative), got ${cases.length}`, nextAction: 'add more cases to the golden trace file' } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { cases };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
interface SpecOptions {
|
|
159
|
+
workspace?: string;
|
|
160
|
+
json?: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function handleRulecodeSpec(opts: SpecOptions): Promise<void> {
|
|
164
|
+
const output: RulecodeSpecOutput = {
|
|
165
|
+
status: 'ok',
|
|
166
|
+
spec: RULECODE_SPEC_TEXT,
|
|
167
|
+
};
|
|
168
|
+
emitResult(output, {
|
|
169
|
+
json: opts.json ?? false,
|
|
170
|
+
formatText: (o) => o.spec,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface ValidateOptions {
|
|
175
|
+
code?: string;
|
|
176
|
+
codeFile?: string;
|
|
177
|
+
workspace?: string;
|
|
178
|
+
json?: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function handleRulecodeValidate(opts: ValidateOptions): Promise<void> {
|
|
182
|
+
const json = opts.json ?? false;
|
|
183
|
+
const codeResult = resolveCode(opts);
|
|
184
|
+
if (codeResult.error || codeResult.code === undefined) {
|
|
185
|
+
const { error } = codeResult;
|
|
186
|
+
const output: RulecodeValidateOutput = {
|
|
187
|
+
status: 'failed',
|
|
188
|
+
valid: false,
|
|
189
|
+
violationCount: 0,
|
|
190
|
+
violations: [],
|
|
191
|
+
reason: error?.reason ?? 'unknown error',
|
|
192
|
+
nextAction: error?.nextAction ?? 'check input and retry',
|
|
193
|
+
};
|
|
194
|
+
emitResult(output, {
|
|
195
|
+
json,
|
|
196
|
+
formatText: (o) => `Error: ${o.reason}\n→ ${o.nextAction}`,
|
|
197
|
+
});
|
|
198
|
+
process.exitCode = 1;
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { code } = codeResult;
|
|
203
|
+
const forbidden = checkForbiddenPatterns(code);
|
|
204
|
+
const missingFields = checkReturnStatementsMissingFields(code);
|
|
205
|
+
const matchedFalseViolations = checkMatchedFalseDecisions(code);
|
|
206
|
+
|
|
207
|
+
const allViolations = [
|
|
208
|
+
...forbidden.map((label) => `forbidden pattern: ${label}`),
|
|
209
|
+
...missingFields,
|
|
210
|
+
...matchedFalseViolations,
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
const output: RulecodeValidateOutput = {
|
|
214
|
+
status: allViolations.length === 0 ? 'ok' : 'failed',
|
|
215
|
+
valid: allViolations.length === 0,
|
|
216
|
+
violationCount: allViolations.length,
|
|
217
|
+
violations: allViolations,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
if (allViolations.length > 0) {
|
|
221
|
+
output.reason = `${allViolations.length} static violation(s) found`;
|
|
222
|
+
output.nextAction = 'fix the listed violations, then run `pd rulecode replay` to verify sandbox behavior';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
emitResult(output, {
|
|
226
|
+
json,
|
|
227
|
+
formatText: (o) => {
|
|
228
|
+
if (o.valid) return 'VALID: no static violations detected.';
|
|
229
|
+
const lines = [`INVALID: ${o.violationCount} violation(s):`];
|
|
230
|
+
for (const v of o.violations) lines.push(` - ${v}`);
|
|
231
|
+
return lines.join('\n');
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (output.status === 'failed') {
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface ReplayOptions {
|
|
241
|
+
code?: string;
|
|
242
|
+
codeFile?: string;
|
|
243
|
+
goldenTrace?: string;
|
|
244
|
+
workspace?: string;
|
|
245
|
+
json?: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function handleRulecodeReplay(opts: ReplayOptions): Promise<void> {
|
|
249
|
+
const json = opts.json ?? false;
|
|
250
|
+
const codeResult = resolveCode(opts);
|
|
251
|
+
if (codeResult.error || codeResult.code === undefined) {
|
|
252
|
+
const { error } = codeResult;
|
|
253
|
+
const output: RulecodeReplayOutput = {
|
|
254
|
+
status: 'failed',
|
|
255
|
+
decision: 'rejected_input',
|
|
256
|
+
reasons: [],
|
|
257
|
+
failedCases: [],
|
|
258
|
+
forbiddenPatternViolations: [],
|
|
259
|
+
reason: error?.reason ?? 'unknown error',
|
|
260
|
+
nextAction: error?.nextAction ?? 'check input and retry',
|
|
261
|
+
};
|
|
262
|
+
emitResult(output, {
|
|
263
|
+
json,
|
|
264
|
+
formatText: (o) => `Error: ${o.reason}\n→ ${o.nextAction}`,
|
|
265
|
+
});
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!opts.goldenTrace) {
|
|
271
|
+
// Should not happen — --golden-trace is requiredOption. Defense-in-depth.
|
|
272
|
+
const output: RulecodeReplayOutput = {
|
|
273
|
+
status: 'failed',
|
|
274
|
+
decision: 'rejected_input',
|
|
275
|
+
reasons: [],
|
|
276
|
+
failedCases: [],
|
|
277
|
+
forbiddenPatternViolations: [],
|
|
278
|
+
reason: '--golden-trace is required',
|
|
279
|
+
nextAction: 'pass --golden-trace <path> with a JSON file of golden trace cases',
|
|
280
|
+
};
|
|
281
|
+
emitResult(output, {
|
|
282
|
+
json,
|
|
283
|
+
formatText: (o) => `Error: ${o.reason}\n→ ${o.nextAction}`,
|
|
284
|
+
});
|
|
285
|
+
process.exitCode = 1;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const traceResult = loadGoldenTraceCases(opts.goldenTrace);
|
|
290
|
+
if (traceResult.error || traceResult.cases === undefined) {
|
|
291
|
+
const { error } = traceResult;
|
|
292
|
+
const output: RulecodeReplayOutput = {
|
|
293
|
+
status: 'failed',
|
|
294
|
+
decision: 'rejected_input',
|
|
295
|
+
reasons: [],
|
|
296
|
+
failedCases: [],
|
|
297
|
+
forbiddenPatternViolations: [],
|
|
298
|
+
reason: error?.reason ?? 'unknown error',
|
|
299
|
+
nextAction: error?.nextAction ?? 'check golden trace file and retry',
|
|
300
|
+
};
|
|
301
|
+
emitResult(output, {
|
|
302
|
+
json,
|
|
303
|
+
formatText: (o) => `Error: ${o.reason}\n→ ${o.nextAction}`,
|
|
304
|
+
});
|
|
305
|
+
process.exitCode = 1;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Build the GoldenTrace from the artificer-input shape.
|
|
310
|
+
const buildResult = buildGoldenTraceFromArtificer({
|
|
311
|
+
cases: traceResult.cases,
|
|
312
|
+
sourceArtifactId: undefined,
|
|
313
|
+
});
|
|
314
|
+
if (!buildResult.ok) {
|
|
315
|
+
const output: RulecodeReplayOutput = {
|
|
316
|
+
status: 'failed',
|
|
317
|
+
decision: 'rejected_input',
|
|
318
|
+
reasons: [],
|
|
319
|
+
failedCases: [],
|
|
320
|
+
forbiddenPatternViolations: [],
|
|
321
|
+
reason: `golden trace build failed: ${buildResult.reason}`,
|
|
322
|
+
nextAction: 'ensure the golden trace has at least 1 positive + 1 negative case',
|
|
323
|
+
};
|
|
324
|
+
emitResult(output, {
|
|
325
|
+
json,
|
|
326
|
+
formatText: (o) => `Error: ${o.reason}\n→ ${o.nextAction}`,
|
|
327
|
+
});
|
|
328
|
+
process.exitCode = 1;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Run the sandbox replay via production gate deps.
|
|
333
|
+
const gateDeps = createProductionGateDeps();
|
|
334
|
+
const gateResult = evaluateRefinerRuleHostGate(
|
|
335
|
+
{ code: codeResult.code, goldenTrace: buildResult.trace },
|
|
336
|
+
gateDeps,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const output: RulecodeReplayOutput = {
|
|
340
|
+
status: gateResult.decision === 'accepted_shadow' ? 'ok' : 'failed',
|
|
341
|
+
decision: gateResult.decision,
|
|
342
|
+
reasons: gateResult.reasons,
|
|
343
|
+
failedCases: gateResult.sandboxResult.failedCases.map((c) => ({
|
|
344
|
+
caseId: c.caseId,
|
|
345
|
+
errorType: c.errorType,
|
|
346
|
+
message: c.message,
|
|
347
|
+
})),
|
|
348
|
+
forbiddenPatternViolations: gateResult.sandboxResult.forbiddenPatternViolations,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
if (output.status === 'failed') {
|
|
352
|
+
output.reason = `sandbox replay rejected: ${gateResult.decision}`;
|
|
353
|
+
output.nextAction = 'fix the code or golden trace cases based on the failed cases above';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
emitResult(output, {
|
|
357
|
+
json,
|
|
358
|
+
formatText: (o) => {
|
|
359
|
+
if (o.status === 'ok') return 'PASSED: all golden trace cases replayed successfully.';
|
|
360
|
+
const lines = [`FAILED: ${o.decision}`];
|
|
361
|
+
for (const r of o.reasons) lines.push(` - ${r}`);
|
|
362
|
+
for (const c of o.failedCases) lines.push(` - caseId: ${c.caseId} | ${c.errorType}: ${c.message}`);
|
|
363
|
+
for (const p of o.forbiddenPatternViolations) lines.push(` - forbidden: ${p}`);
|
|
364
|
+
return lines.join('\n');
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (output.status === 'failed') {
|
|
369
|
+
process.exitCode = 1;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Registration ─────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Register the `rulecode` parent command with 3 subcommands (spec, validate,
|
|
377
|
+
* replay). Returns the parent command for chaining.
|
|
378
|
+
*
|
|
379
|
+
* This is the single source of truth for flag registration — both index.ts
|
|
380
|
+
* and parser tests call this function (CLI gate rule 7).
|
|
381
|
+
*/
|
|
382
|
+
export function registerRulecodeCommand(parentCmd: Command): Command {
|
|
383
|
+
const rulecodeCmd = parentCmd
|
|
384
|
+
.command('rulecode')
|
|
385
|
+
.description('RuleCode dialect spec, static validation, and sandbox replay (read-only)');
|
|
386
|
+
|
|
387
|
+
// spec — no code input needed
|
|
388
|
+
rulecodeCmd
|
|
389
|
+
.command('spec')
|
|
390
|
+
.description('Print the RuleCode dialect spec text (canonical form, forbidden patterns, return shape)')
|
|
391
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
392
|
+
.option('--json', 'Output raw JSON')
|
|
393
|
+
.action(async (opts) => {
|
|
394
|
+
await handleRulecodeSpec({ workspace: opts.workspace, json: opts.json });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// validate — static validation only
|
|
398
|
+
rulecodeCmd
|
|
399
|
+
.command('validate')
|
|
400
|
+
.description('Run static validation on rule implementation code (forbidden patterns + return shape)')
|
|
401
|
+
.option('--code <string>', 'Rule implementation source code (inline)')
|
|
402
|
+
.option('--code-file <path>', 'Read rule implementation code from file')
|
|
403
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
404
|
+
.option('--json', 'Output raw JSON')
|
|
405
|
+
.action(async (opts) => {
|
|
406
|
+
await handleRulecodeValidate({
|
|
407
|
+
code: opts.code,
|
|
408
|
+
codeFile: opts.codeFile,
|
|
409
|
+
workspace: opts.workspace,
|
|
410
|
+
json: opts.json,
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// replay — sandbox replay against a golden trace
|
|
415
|
+
rulecodeCmd
|
|
416
|
+
.command('replay')
|
|
417
|
+
.description('Run sandbox replay of rule code against a golden trace JSON file')
|
|
418
|
+
.option('--code <string>', 'Rule implementation source code (inline)')
|
|
419
|
+
.option('--code-file <path>', 'Read rule implementation code from file')
|
|
420
|
+
.requiredOption('--golden-trace <path>', 'JSON file containing golden trace cases array')
|
|
421
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
422
|
+
.option('--json', 'Output raw JSON')
|
|
423
|
+
.action(async (opts) => {
|
|
424
|
+
await handleRulecodeReplay({
|
|
425
|
+
code: opts.code,
|
|
426
|
+
codeFile: opts.codeFile,
|
|
427
|
+
goldenTrace: opts.goldenTrace,
|
|
428
|
+
workspace: opts.workspace,
|
|
429
|
+
json: opts.json,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
return rulecodeCmd;
|
|
434
|
+
}
|
|
@@ -31,7 +31,6 @@ import { createSandboxGateDeps } from '../services/rulehost-pipeline-runner.js';
|
|
|
31
31
|
import {
|
|
32
32
|
PiAiRuntimeAdapter,
|
|
33
33
|
ArtificerL2Adapter,
|
|
34
|
-
buildArtificerL2GenerateCode,
|
|
35
34
|
DefaultArtificerValidator,
|
|
36
35
|
resolveAgentRuntimeBinding,
|
|
37
36
|
computeFeatureFlagsFromConfig,
|
|
@@ -184,18 +183,14 @@ function resolveRunRuleHostRuntime(
|
|
|
184
183
|
agentRuntimeProfiles.artificer = artificerBinding.profileId;
|
|
185
184
|
agentRuntimeProfiles.evaluator = evaluator.profileId;
|
|
186
185
|
|
|
187
|
-
const
|
|
186
|
+
const artificerAdapter = new ArtificerL2Adapter({
|
|
188
187
|
provider: artificerProfile.provider,
|
|
189
188
|
model: artificerProfile.model,
|
|
190
|
-
|
|
189
|
+
apiKeyEnv: artificerProfile.apiKeyEnv,
|
|
191
190
|
baseUrl: artificerProfile.baseUrl,
|
|
192
|
-
timeoutMs,
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
const artificerAdapter = new ArtificerL2Adapter({
|
|
196
|
-
generateCode,
|
|
197
191
|
gateDeps: createSandboxGateDeps(),
|
|
198
192
|
validator: new DefaultArtificerValidator(),
|
|
193
|
+
totalBudgetMs: timeoutMs,
|
|
199
194
|
});
|
|
200
195
|
|
|
201
196
|
return {
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { handleDemoStoryA } from './commands/demo-story-a.js';
|
|
|
53
53
|
import { handleRuntimeFeaturesStatus } from './commands/runtime-features.js';
|
|
54
54
|
import { handleConfigDoctor } from './commands/config-doctor.js';
|
|
55
55
|
import { registerMvpCommands } from './commands/mvp-smoke.js';
|
|
56
|
+
import { registerRulecodeCommand } from './commands/rulecode.js';
|
|
56
57
|
|
|
57
58
|
import { createRequire } from 'module';
|
|
58
59
|
const require = createRequire(import.meta.url);
|
|
@@ -895,23 +896,44 @@ artifactCmd
|
|
|
895
896
|
|
|
896
897
|
const _legacyCleanupCmd = legacyCmd
|
|
897
898
|
.command('cleanup')
|
|
898
|
-
.description('Clean legacy empathy/diagnostician artifacts from workspace')
|
|
899
|
+
.description('Clean legacy empathy/diagnostician artifacts and V1 Artificer artifacts from workspace')
|
|
899
900
|
.requiredOption('-w, --workspace <path>', 'Workspace directory')
|
|
900
|
-
.option('--dry-run', 'Show what would be cleaned without applying
|
|
901
|
-
.option('--apply', 'Actually apply the cleanup'
|
|
902
|
-
.
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
901
|
+
.option('--dry-run', 'Show what would be cleaned without applying (default)')
|
|
902
|
+
.option('--apply', 'Actually apply the cleanup')
|
|
903
|
+
.option('--json', 'Output raw JSON')
|
|
904
|
+
.action(async (opts) => {
|
|
905
|
+
// CLI gate rule 4: --dry-run and --apply are mutually exclusive
|
|
906
|
+
if (opts.dryRun && opts.apply) {
|
|
907
|
+
const msg = 'Error: --dry-run and --apply are mutually exclusive';
|
|
908
|
+
if (opts.json) {
|
|
909
|
+
console.log(JSON.stringify({ status: 'failed', reason: msg, nextAction: 'Specify either --dry-run or --apply, not both' }, null, 2));
|
|
910
|
+
} else {
|
|
911
|
+
console.error(msg);
|
|
912
|
+
}
|
|
913
|
+
process.exitCode = 1;
|
|
914
|
+
return;
|
|
907
915
|
}
|
|
908
|
-
|
|
916
|
+
// Default to dry-run if neither flag is set (CLI gate rule 4).
|
|
917
|
+
// Pass undefined through — the handler's logic
|
|
918
|
+
// (opts.apply === true ? false : opts.dryRun !== false) correctly
|
|
919
|
+
// defaults to dry-run when both are undefined.
|
|
920
|
+
await handleLegacyCleanup({
|
|
921
|
+
workspacePath: opts.workspace,
|
|
922
|
+
dryRun: opts.dryRun,
|
|
923
|
+
apply: opts.apply,
|
|
924
|
+
json: opts.json ?? false,
|
|
925
|
+
});
|
|
909
926
|
});
|
|
910
927
|
|
|
911
928
|
// ─── MVP Smoke (PRI-397) ────────────────────────────────────────────────────
|
|
912
929
|
|
|
913
930
|
registerMvpCommands(program);
|
|
914
931
|
|
|
932
|
+
// ─── RuleCode CLI (PRI-439 Phase 5) ─────────────────────────────────────────
|
|
933
|
+
// Read-only commands: spec, validate, replay. No DB mutation, no artifact writes.
|
|
934
|
+
|
|
935
|
+
registerRulecodeCommand(program);
|
|
936
|
+
|
|
915
937
|
const consoleCmd = program
|
|
916
938
|
.command('console')
|
|
917
939
|
.description('Start the pd-console web UI for principle review (default: legacy launcher)')
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
EvaluatorRunner,
|
|
40
40
|
DefaultEvaluatorValidator,
|
|
41
41
|
createPITaskDiagnosticJson,
|
|
42
|
+
parsePITaskMetadata,
|
|
42
43
|
runAdversarialLoop,
|
|
43
44
|
evaluateInRefinerSandbox,
|
|
44
45
|
DEFAULT_MAX_ROUNDS,
|
|
@@ -263,7 +264,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
|
|
|
263
264
|
// D fix (PRI-429): exact sourcePainId match via Object.hasOwn on parsed
|
|
264
265
|
// diagnosticJson. No substring matching (pain-1 must NOT match pain-10).
|
|
265
266
|
onProgress('pain_lookup', 'start', `painId=${opts.painId}`);
|
|
266
|
-
const dreamerLookup = await findDreamerTaskForPain(stateManager, opts.painId);
|
|
267
|
+
const dreamerLookup = await findDreamerTaskForPain(stateManager, opts.painId, channel);
|
|
267
268
|
if (dreamerLookup.status === 'ambiguous') {
|
|
268
269
|
const reason = `ambiguous_dreamer_tasks_for_pain: ${dreamerLookup.taskIds.join(',')}`;
|
|
269
270
|
stages.push({ name: 'pain_lookup', status: 'failed', reason });
|
|
@@ -281,15 +282,19 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
|
|
|
281
282
|
|
|
282
283
|
// ── Stage: dreamer ──
|
|
283
284
|
onProgress('dreamer', 'start');
|
|
284
|
-
|
|
285
|
-
{
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
285
|
+
if (dreamerLookup.taskStatus === 'succeeded') {
|
|
286
|
+
stages.push({ name: 'dreamer', taskId: dreamerSeedTaskId, status: 'succeeded' });
|
|
287
|
+
} else {
|
|
288
|
+
const dreamerRunner = new DreamerRunner(
|
|
289
|
+
{ stateManager, runtimeAdapter: agentAdapters.dreamer, eventEmitter, validator: new DefaultDreamerValidator(), artifactStore },
|
|
290
|
+
runnerOptsFor(agentAdapters.dreamer),
|
|
291
|
+
);
|
|
292
|
+
const dreamerResult = await runStage(dreamerRunner, dreamerSeedTaskId, { maxStageRetries, pollIntervalMs });
|
|
293
|
+
stages.push(stageFromResult('dreamer', dreamerSeedTaskId, dreamerResult));
|
|
294
|
+
if (dreamerResult.status !== 'succeeded') {
|
|
295
|
+
onProgress('dreamer', 'failed', dreamerResult.failureReason);
|
|
296
|
+
return rejectedResult(opts.painId, stages, `dreamer_failed: ${dreamerResult.failureReason ?? dreamerResult.status}`);
|
|
297
|
+
}
|
|
293
298
|
}
|
|
294
299
|
onProgress('dreamer', 'succeeded');
|
|
295
300
|
|
|
@@ -451,18 +456,25 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
|
|
|
451
456
|
* - ERR-009: missing sourcePainId = no match (fail loud, not silent skip)
|
|
452
457
|
*/
|
|
453
458
|
type DreamerTaskLookup =
|
|
454
|
-
| { readonly status: 'found'; readonly taskId: string }
|
|
459
|
+
| { readonly status: 'found'; readonly taskId: string; readonly taskStatus: 'pending' | 'retry_wait' | 'succeeded' }
|
|
455
460
|
| { readonly status: 'not_found' }
|
|
456
461
|
| { readonly status: 'ambiguous'; readonly taskIds: readonly string[] };
|
|
457
462
|
|
|
458
|
-
async function findDreamerTaskForPain(
|
|
463
|
+
async function findDreamerTaskForPain(
|
|
464
|
+
stateManager: RuntimeStateManager,
|
|
465
|
+
painId: string,
|
|
466
|
+
channel: 'prompt' | 'code_tool_hook' | 'defer_archive',
|
|
467
|
+
): Promise<DreamerTaskLookup> {
|
|
459
468
|
const tasks = await stateManager.listTasks();
|
|
460
469
|
const dreamerTasks = tasks.filter((t) =>
|
|
461
|
-
t.taskKind === 'dreamer' && (t.status === 'pending' || t.status === 'retry_wait'),
|
|
470
|
+
t.taskKind === 'dreamer' && (t.status === 'pending' || t.status === 'retry_wait' || t.status === 'succeeded'),
|
|
462
471
|
);
|
|
463
|
-
const
|
|
472
|
+
const allMatches: typeof dreamerTasks = [];
|
|
473
|
+
const channelMatches: typeof dreamerTasks = [];
|
|
464
474
|
for (const t of dreamerTasks) {
|
|
465
475
|
if (typeof t.diagnosticJson !== 'string') continue;
|
|
476
|
+
const metadata = parsePITaskMetadata(t.diagnosticJson);
|
|
477
|
+
if (!metadata) continue;
|
|
466
478
|
let parsed: unknown;
|
|
467
479
|
try {
|
|
468
480
|
parsed = JSON.parse(t.diagnosticJson);
|
|
@@ -476,13 +488,19 @@ async function findDreamerTaskForPain(stateManager: RuntimeStateManager, painId:
|
|
|
476
488
|
if (!Object.hasOwn(parsed, 'sourcePainId')) continue;
|
|
477
489
|
const stored = Reflect.get(parsed, 'sourcePainId');
|
|
478
490
|
if (typeof stored === 'string' && stored === painId) {
|
|
479
|
-
|
|
491
|
+
allMatches.push(t);
|
|
492
|
+
if (metadata.channel === channel) channelMatches.push(t);
|
|
480
493
|
}
|
|
481
494
|
}
|
|
482
|
-
matches.
|
|
495
|
+
const matches = channelMatches.length > 0 ? channelMatches : allMatches;
|
|
496
|
+
matches.sort((left, right) => left.taskId.localeCompare(right.taskId));
|
|
483
497
|
if (matches.length === 0) return { status: 'not_found' };
|
|
484
|
-
if (matches.length > 1) return { status: 'ambiguous', taskIds: matches };
|
|
485
|
-
|
|
498
|
+
if (matches.length > 1) return { status: 'ambiguous', taskIds: matches.map((task) => task.taskId) };
|
|
499
|
+
const [match] = matches;
|
|
500
|
+
if (!match || (match.status !== 'pending' && match.status !== 'retry_wait' && match.status !== 'succeeded')) {
|
|
501
|
+
return { status: 'not_found' };
|
|
502
|
+
}
|
|
503
|
+
return { status: 'found', taskId: match.taskId, taskStatus: match.status };
|
|
486
504
|
}
|
|
487
505
|
|
|
488
506
|
// eslint-disable-next-line @typescript-eslint/max-params
|
|
@@ -519,3 +519,47 @@ describe('PRI-435: candidate internalize rejects non-diagnostician task for line
|
|
|
519
519
|
expect(Object.hasOwn(parsed, 'nextAction'), 'nextAction field must be present').toBe(true);
|
|
520
520
|
});
|
|
521
521
|
});
|
|
522
|
+
|
|
523
|
+
describe('candidate internalize resolves the production diag_router candidate chain', () => {
|
|
524
|
+
it('follows the router run diagnosisId back to the originating diagnostician task', async () => {
|
|
525
|
+
tmpDir = makeTmpDir();
|
|
526
|
+
const sm = new RuntimeStateManager({ workspaceDir: tmpDir });
|
|
527
|
+
await sm.initialize();
|
|
528
|
+
const painId = 'pain-router-lineage-001';
|
|
529
|
+
const seeded = await seedDiagnosisToCandidate(sm, painId);
|
|
530
|
+
const routerTaskId = `diag_router-${seeded.taskId}`;
|
|
531
|
+
await sm.createTask({
|
|
532
|
+
taskId: routerTaskId, taskKind: 'diag_router', status: 'pending',
|
|
533
|
+
attemptCount: 0, maxAttempts: 3, diagnosticJson: JSON.stringify({ pi_metadata: {
|
|
534
|
+
dependencyTaskIds: [], channel: 'prompt', timeoutMs: 1000,
|
|
535
|
+
inputArtifactRefs: [], outputArtifactRefs: [],
|
|
536
|
+
} }),
|
|
537
|
+
});
|
|
538
|
+
await sm.acquireLease({ taskId: routerTaskId, owner: 'test', durationMs: 60_000, runtimeKind: 'openclaw' });
|
|
539
|
+
const [routerRun] = await sm.getRunsByTask(routerTaskId);
|
|
540
|
+
if (!routerRun) throw new Error('router run not created');
|
|
541
|
+
await sm.updateRunOutput(routerRun.runId, JSON.stringify({ diagnosisId: seeded.taskId }));
|
|
542
|
+
sm.connection.getDb().prepare(
|
|
543
|
+
'UPDATE principle_candidates SET task_id = ?, source_run_id = ? WHERE candidate_id = ?',
|
|
544
|
+
).run(routerTaskId, routerRun.runId, seeded.candidateId);
|
|
545
|
+
await sm.close();
|
|
546
|
+
|
|
547
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
548
|
+
try {
|
|
549
|
+
await handleCandidateInternalize({ candidateId: seeded.candidateId, workspace: tmpDir, json: true });
|
|
550
|
+
} finally {
|
|
551
|
+
logSpy.mockRestore();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const verify = new RuntimeStateManager({ workspaceDir: tmpDir });
|
|
555
|
+
await verify.initialize();
|
|
556
|
+
const dreamer = (await verify.listTasks()).find((task) => task.taskKind === 'dreamer');
|
|
557
|
+
expect(dreamer).toBeDefined();
|
|
558
|
+
const diagnostic: unknown = JSON.parse(dreamer?.diagnosticJson ?? '{}');
|
|
559
|
+
if (diagnostic === null || typeof diagnostic !== 'object' || Array.isArray(diagnostic)) {
|
|
560
|
+
throw new Error('dreamer diagnosticJson must be an object');
|
|
561
|
+
}
|
|
562
|
+
expect(Reflect.get(diagnostic, 'sourcePainId')).toBe(painId);
|
|
563
|
+
await verify.close();
|
|
564
|
+
});
|
|
565
|
+
});
|