@principles/pd-cli 1.145.1 → 1.146.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/README.md +11 -2
- package/dist/commands/build-trajectory-evidence.d.ts +25 -6
- package/dist/commands/build-trajectory-evidence.d.ts.map +1 -1
- package/dist/commands/build-trajectory-evidence.js +174 -114
- package/dist/commands/build-trajectory-evidence.js.map +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +48 -27
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/pain-record.d.ts.map +1 -1
- package/dist/commands/pain-record.js +212 -17
- package/dist/commands/pain-record.js.map +1 -1
- package/dist/commands/pain-retry.d.ts.map +1 -1
- package/dist/commands/pain-retry.js +43 -26
- package/dist/commands/pain-retry.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/build-trajectory-evidence.ts +211 -122
- package/src/commands/diagnose.ts +59 -36
- package/src/commands/pain-record.ts +224 -16
- package/src/commands/pain-retry.ts +56 -36
- package/src/index.ts +1 -1
- package/tests/bdd/cli-contract.steps.ts +3 -0
- package/tests/commands/build-trajectory-evidence.test.ts +226 -161
- package/tests/commands/diagnose.test.ts +82 -0
- package/tests/commands/pain-record-async.test.ts +36 -30
- package/tests/commands/pain-record-session-parser.test.ts +127 -0
- package/tests/commands/pain-record.test.ts +205 -37
- package/tests/commands/pain-retry.test.ts +72 -1
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
* pd pain record command — Runtime v2 pain signal entry point.
|
|
3
3
|
*
|
|
4
4
|
* Uses PainToPrincipleService as the single write-side orchestration API.
|
|
5
|
+
* PRI-642 (review blocker 1): the ingress semantic interpretation — origin,
|
|
6
|
+
* bound/unbound correlation, provenance derivation, sentinel handling,
|
|
7
|
+
* submit/degrade/refuse semantics and painIngress.v1 construction — comes
|
|
8
|
+
* from the SHARED evaluator `evaluatePainIngress` in @principles/core
|
|
9
|
+
* (the same authority the OpenClaw funnel uses). This adapter only does
|
|
10
|
+
* host-specific ACQUISITION: read --session, validate it against this
|
|
11
|
+
* workspace's trajectory.db, and collect raw evidence entries.
|
|
5
12
|
*
|
|
6
13
|
* Usage:
|
|
7
14
|
* pd pain record --reason <text> [--score N] [--source manual] [--workspace <path>] [--session <id>] [--json]
|
|
@@ -13,10 +20,12 @@ import {
|
|
|
13
20
|
isRuntimeConfigError,
|
|
14
21
|
isFeatureEnabled,
|
|
15
22
|
isBuiltinPiAiProvider,
|
|
23
|
+
evaluatePainIngress,
|
|
16
24
|
} from '@principles/core/runtime-v2';
|
|
25
|
+
import type { PainIngressDecision, IngressEvidenceEntry, PainEvidenceEntry } from '@principles/core/runtime-v2';
|
|
17
26
|
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
18
27
|
import { loadPdConfig, computeFlagsFromLoadResult } from '../services/pd-config-loader.js';
|
|
19
|
-
import {
|
|
28
|
+
import { acquireTrajectoryEvidenceFromDb } from './build-trajectory-evidence.js';
|
|
20
29
|
|
|
21
30
|
interface RecordOptions {
|
|
22
31
|
reason?: string;
|
|
@@ -28,6 +37,125 @@ interface RecordOptions {
|
|
|
28
37
|
wait?: boolean;
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
function emitSessionBindingFailure(
|
|
41
|
+
opts: RecordOptions,
|
|
42
|
+
failure: { reason: string; message: string; nextAction: string },
|
|
43
|
+
): void {
|
|
44
|
+
if (opts.json) {
|
|
45
|
+
console.log(JSON.stringify({
|
|
46
|
+
status: 'failed',
|
|
47
|
+
reason: failure.reason,
|
|
48
|
+
message: failure.message,
|
|
49
|
+
nextAction: failure.nextAction,
|
|
50
|
+
}, null, 2));
|
|
51
|
+
} else {
|
|
52
|
+
console.error(`Error: ${failure.message}`);
|
|
53
|
+
console.error(`Reason: ${failure.reason}`);
|
|
54
|
+
console.error(`Next action: ${failure.nextAction}`);
|
|
55
|
+
}
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toIngressEntry(entry: PainEvidenceEntry): IngressEvidenceEntry {
|
|
60
|
+
// Trajectory evidence references observed behavior (owner messages,
|
|
61
|
+
// assistant turns, tool-call failures) — behavior traces.
|
|
62
|
+
return { kind: 'behavior_trace', sourceRef: entry.sourceRef, note: entry.note };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* PRI-642 (SPEC §7.3/§7.4, review blocker 1): build the report from this
|
|
67
|
+
* adapter's host-specific facts and let the SHARED evaluator decide.
|
|
68
|
+
*
|
|
69
|
+
* Per-channel policy (SPEC §7.3 vs OpenClaw):
|
|
70
|
+
*
|
|
71
|
+
* - explicit `--session` + acquisition available (real session + real entries):
|
|
72
|
+
* → cli_explicit_session + bound → submit with real evidence.
|
|
73
|
+
* - explicit `--session` + acquisition unavailable (session_not_found /
|
|
74
|
+
* empty_trajectory / trajectory_unavailable / evidence_read_failed):
|
|
75
|
+
* → cli_explicit_session + unbound → shared evaluator REFUSES
|
|
76
|
+
* (row 5) before any LLM/task/candidate mutation.
|
|
77
|
+
* The CLI does NOT share the OpenClaw funnel's "degrade" semantics
|
|
78
|
+
* because the CLI does not own the session identity the way the host
|
|
79
|
+
* command context does (Evidence Over Assumption — an unverified
|
|
80
|
+
* session claimed as bound would be a PRI-642 recurrence).
|
|
81
|
+
* - no `--session` → unbound Owner report (matrix row 6) → submit with
|
|
82
|
+
* disclosure; no sentinel session, no placeholder evidence, no trajectory
|
|
83
|
+
* projection (the CLI skips observability when the ingress yields no
|
|
84
|
+
* bound session — SPEC §7.4).
|
|
85
|
+
*/
|
|
86
|
+
function resolveIngressDecision(
|
|
87
|
+
input: { opts: RecordOptions; stateDir: string; workspaceDir: string; painId: string },
|
|
88
|
+
): { decision: PainIngressDecision; acquisitionDetail: string | null; acquisitionReason: string | null } {
|
|
89
|
+
const { opts, stateDir, workspaceDir, painId } = input;
|
|
90
|
+
const score = opts.score ?? 80;
|
|
91
|
+
const base = {
|
|
92
|
+
identity: { kind: 'manual_pain_id' as const, painId },
|
|
93
|
+
painType: 'user_frustration' as const,
|
|
94
|
+
source: opts.source ?? 'manual',
|
|
95
|
+
reason: opts.reason ?? '',
|
|
96
|
+
score,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (!opts.session) {
|
|
100
|
+
// No --session: external unbound Owner report (matrix row 6). Allowed
|
|
101
|
+
// by SPEC §7.4 with a disclosure warning; never claims host binding.
|
|
102
|
+
const decision = evaluatePainIngress({
|
|
103
|
+
...base,
|
|
104
|
+
origin: { kind: 'owner_manual', channel: 'external_cli_unbound' },
|
|
105
|
+
correlation: { status: 'unbound', reason: 'external_cli' },
|
|
106
|
+
evidence: { status: 'unavailable', reason: 'not_applicable_unbound' },
|
|
107
|
+
});
|
|
108
|
+
return { decision, acquisitionDetail: null, acquisitionReason: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --session: SPEC §7.3 demands a fail-loud refusal whenever the CLI
|
|
112
|
+
// cannot actually verify the claimed binding. We do not share the
|
|
113
|
+
// OpenClaw funnel's "degrade" semantics here because the CLI does
|
|
114
|
+
// not own the session identity the way the host command context does
|
|
115
|
+
// (Evidence Over Assumption): an unverified session that we still
|
|
116
|
+
// claimed as bound would be a PRI-642 recurrence in a different
|
|
117
|
+
// shape. Per-channel policy differs; semantic authority is shared.
|
|
118
|
+
const acquisition = acquireTrajectoryEvidenceFromDb(stateDir, opts.session, workspaceDir);
|
|
119
|
+
if (acquisition.status === 'unavailable') {
|
|
120
|
+
// session_not_found / empty_trajectory / trajectory_unavailable /
|
|
121
|
+
// evidence_read_failed — refuse (row 5) before any LLM/task/candidate
|
|
122
|
+
// mutation. The CLI surfaces the reasonCode with a SPEC §7.3 next
|
|
123
|
+
// action; the evaluator produces the refuse decision.
|
|
124
|
+
const {reasonCode} = acquisition;
|
|
125
|
+
const decision = evaluatePainIngress({
|
|
126
|
+
...base,
|
|
127
|
+
origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
|
|
128
|
+
correlation: { status: 'unbound', reason: 'external_cli' },
|
|
129
|
+
evidence: { status: 'unavailable', reason: reasonCode === 'session_not_found' ? 'not_applicable_unbound' : reasonCode },
|
|
130
|
+
});
|
|
131
|
+
if (decision.action === 'refuse') {
|
|
132
|
+
return { decision, acquisitionDetail: acquisition.detail, acquisitionReason: reasonCode };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Available evidence: a real session with real entries — submit, bound.
|
|
137
|
+
if (acquisition.status === 'available') {
|
|
138
|
+
const decision = evaluatePainIngress({
|
|
139
|
+
...base,
|
|
140
|
+
origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
|
|
141
|
+
correlation: { status: 'bound', hostKind: 'openclaw', sessionId: opts.session },
|
|
142
|
+
evidence: { status: 'available', entries: acquisition.entries.map(toIngressEntry) as [IngressEvidenceEntry, ...IngressEvidenceEntry[]] },
|
|
143
|
+
});
|
|
144
|
+
return { decision, acquisitionDetail: null, acquisitionReason: null };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Exhaustive — acquireTrajectoryEvidenceFromDb returns {available, unavailable}
|
|
148
|
+
// only. The empty-trajectory branch was collapsed to refuse above; a
|
|
149
|
+
// future-added reason would land here and must also refuse.
|
|
150
|
+
const decision = evaluatePainIngress({
|
|
151
|
+
...base,
|
|
152
|
+
origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
|
|
153
|
+
correlation: { status: 'unbound', reason: 'external_cli' },
|
|
154
|
+
evidence: { status: 'unavailable', reason: 'not_applicable_unbound' },
|
|
155
|
+
});
|
|
156
|
+
return { decision, acquisitionDetail: null, acquisitionReason: null };
|
|
157
|
+
}
|
|
158
|
+
|
|
31
159
|
export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
32
160
|
if (!opts.reason) {
|
|
33
161
|
console.error('Error: --reason <text> is required');
|
|
@@ -50,24 +178,57 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
|
50
178
|
}, null, 2));
|
|
51
179
|
} else {
|
|
52
180
|
console.error(`Error: --reason must be at most ${MAX_REASON_LENGTH} characters (got ${opts.reason.length})`);
|
|
53
|
-
console.error('Next action: shorten the reason text or split into multiple pain records.');
|
|
181
|
+
console.error('Next action: shorten the reason text or split it into multiple pain records.');
|
|
54
182
|
}
|
|
55
183
|
process.exit(1);
|
|
56
184
|
return;
|
|
57
185
|
}
|
|
58
186
|
|
|
59
187
|
if (opts.score !== undefined && (isNaN(opts.score) || opts.score < 0 || opts.score > 100)) {
|
|
60
|
-
|
|
188
|
+
if (opts.json) {
|
|
189
|
+
console.log(JSON.stringify({
|
|
190
|
+
status: 'failed',
|
|
191
|
+
reason: 'score_invalid',
|
|
192
|
+
message: '--score must be a number between 0 and 100',
|
|
193
|
+
nextAction: 'Provide --score as an integer from 0 to 100, or omit it for the default (80).',
|
|
194
|
+
}, null, 2));
|
|
195
|
+
} else {
|
|
196
|
+
console.error('Error: --score must be a number between 0 and 100');
|
|
197
|
+
console.error('Next action: provide --score as an integer from 0 to 100, or omit it for the default (80).');
|
|
198
|
+
}
|
|
61
199
|
process.exit(1);
|
|
200
|
+
return;
|
|
62
201
|
}
|
|
63
202
|
|
|
64
203
|
const workspaceDir = resolveWorkspaceDir(opts.workspace);
|
|
65
204
|
const stateDir = `${workspaceDir}/.state`;
|
|
66
205
|
const painId = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
67
206
|
|
|
68
|
-
// PRI-
|
|
69
|
-
|
|
70
|
-
const
|
|
207
|
+
// PRI-642: run the SHARED ingress evaluator before any service/LLM/task
|
|
208
|
+
// mutation; the decision (submit/degrade/refuse) is not made here.
|
|
209
|
+
const { decision, acquisitionDetail, acquisitionReason } = resolveIngressDecision({ opts, stateDir, workspaceDir, painId });
|
|
210
|
+
|
|
211
|
+
if (decision.action === 'refuse') {
|
|
212
|
+
// SPEC §7.3: the CLI must surface the specific acquisition reason
|
|
213
|
+
// (session_not_found / empty_trajectory / trajectory_unavailable /
|
|
214
|
+
// evidence_read_failed) — the shared evaluator produces the refuse
|
|
215
|
+
// decision; we map the acquisition detail to the Operator reason.
|
|
216
|
+
const reason = opts.session !== undefined && acquisitionReason !== null
|
|
217
|
+
? acquisitionReason
|
|
218
|
+
: decision.reasonCode;
|
|
219
|
+
const detailSuffix = acquisitionDetail !== null ? ` (${acquisitionDetail})` : '';
|
|
220
|
+
emitSessionBindingFailure(opts, {
|
|
221
|
+
reason,
|
|
222
|
+
message: `${decision.warning}${detailSuffix}`,
|
|
223
|
+
nextAction: decision.nextAction,
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const binding = decision.legacy;
|
|
229
|
+
// SPEC §7.4: without a bound session the trajectory projection is skipped
|
|
230
|
+
// (disclosed below) instead of fabricating the sentinel session 'cli'.
|
|
231
|
+
const recordObservability = binding.sessionId !== undefined;
|
|
71
232
|
|
|
72
233
|
const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir });
|
|
73
234
|
// PRI-306: Load .pd/config.yaml for config-driven runtime binding
|
|
@@ -104,11 +265,13 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
|
104
265
|
source: opts.source ?? 'manual',
|
|
105
266
|
reason: opts.reason,
|
|
106
267
|
score: opts.score ?? 80,
|
|
107
|
-
sessionId:
|
|
268
|
+
sessionId: binding.sessionId,
|
|
108
269
|
agentId: 'pd-cli',
|
|
109
|
-
provenance:
|
|
110
|
-
|
|
111
|
-
|
|
270
|
+
provenance: binding.provenance,
|
|
271
|
+
hostKind: binding.hostKind,
|
|
272
|
+
evidence: binding.evidence,
|
|
273
|
+
recordObservability,
|
|
274
|
+
painIngress: binding.painIngress,
|
|
112
275
|
});
|
|
113
276
|
|
|
114
277
|
// Show diagnostic info for config failures
|
|
@@ -181,6 +344,25 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
|
181
344
|
}
|
|
182
345
|
}
|
|
183
346
|
|
|
347
|
+
// PRI-642 (SPEC §12.1.6): generated-but-unadmitted candidates must never
|
|
348
|
+
// read as completed internalization — surface the admission disposition.
|
|
349
|
+
const admissionDecisions = result.admissionResults?.map(r => r.admission.decision) ?? [];
|
|
350
|
+
const admittedCount = admissionDecisions.filter(d => d === 'admitted').length;
|
|
351
|
+
const gatedWarning = result.status === 'succeeded'
|
|
352
|
+
&& result.candidateIds.length > 0
|
|
353
|
+
&& admittedCount === 0
|
|
354
|
+
? `admitted: 0 of ${result.candidateIds.length} candidates (all gated — needs_evidence/deferred); `
|
|
355
|
+
+ 'nothing was internalized. See admissionResults for per-candidate reasons; add --session evidence or Owner context to raise confidence above the admission threshold.'
|
|
356
|
+
: null;
|
|
357
|
+
// PRI-642: the ingress decision's own disclosures (degrade warning /
|
|
358
|
+
// unbound context_unbound warning) are surfaced to the operator.
|
|
359
|
+
const decisionWarnings = decision.action === 'degrade'
|
|
360
|
+
? [`${decision.warning} Next action: ${decision.nextAction}`]
|
|
361
|
+
: decision.action === 'submit'
|
|
362
|
+
? decision.warnings
|
|
363
|
+
: [];
|
|
364
|
+
const cliWarnings = [...decisionWarnings, gatedWarning].filter((w): w is string => w !== null && w !== undefined);
|
|
365
|
+
|
|
184
366
|
if (opts.json) {
|
|
185
367
|
const out: Record<string, unknown> = { ...result };
|
|
186
368
|
// Ensure nextAction is present for actionable states
|
|
@@ -191,13 +373,22 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
|
191
373
|
if (!out.reason) {
|
|
192
374
|
out.reason = out.message;
|
|
193
375
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
376
|
+
}
|
|
377
|
+
// PRI-570: async submission has NO automatic consumer — the
|
|
378
|
+
// internalization auto-consumer's runner kinds (dreamer..rollout_reviewer)
|
|
379
|
+
// do not include 'diagnostician', so a submitted task stays pending until
|
|
380
|
+
// the owner runs the diagnose command. Never let that stay implicit (rc-9).
|
|
381
|
+
// PRI-642: unbound/gated disclosures join the same warnings channel.
|
|
382
|
+
const submittedWarning = out.status === 'submitted'
|
|
383
|
+
? 'Async mode: no background consumer picks up diagnostician tasks. '
|
|
199
384
|
+ `Run ${out.nextAction} or the pain signal will remain pending indefinitely. `
|
|
200
|
-
+ 'Use --wait to diagnose synchronously instead.'
|
|
385
|
+
+ 'Use --wait to diagnose synchronously instead.'
|
|
386
|
+
: null;
|
|
387
|
+
const allWarnings = [...cliWarnings];
|
|
388
|
+
if (submittedWarning !== null) allWarnings.unshift(submittedWarning);
|
|
389
|
+
if (allWarnings.length > 0) {
|
|
390
|
+
out.warning = allWarnings.join(' ');
|
|
391
|
+
out.warnings = allWarnings;
|
|
201
392
|
}
|
|
202
393
|
console.log(JSON.stringify(out, null, 2));
|
|
203
394
|
if (result.status !== 'succeeded' && result.status !== 'skipped' && result.status !== 'retried' && result.status !== 'submitted') {
|
|
@@ -213,11 +404,28 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
|
213
404
|
if (result.artifactId) console.log(` Artifact ID: ${result.artifactId}`);
|
|
214
405
|
if (result.candidateIds.length > 0) console.log(` Candidate IDs: ${result.candidateIds.join(', ')}`);
|
|
215
406
|
if (result.ledgerEntryIds.length > 0) console.log(` Ledger Entry IDs: ${result.ledgerEntryIds.join(', ')}`);
|
|
407
|
+
if (result.progress && result.progress.seededTaskIds.length > 0) {
|
|
408
|
+
console.log(` Seeded Task IDs: ${result.progress.seededTaskIds.join(', ')}`);
|
|
409
|
+
}
|
|
216
410
|
console.log(` Reason: ${opts.reason}`);
|
|
217
411
|
console.log(` Score: ${opts.score ?? 80}`);
|
|
218
412
|
console.log(` Source: ${opts.source ?? 'manual'}`);
|
|
219
413
|
console.log(` Workspace: ${workspaceDir}`);
|
|
414
|
+
if (binding.sessionId) {
|
|
415
|
+
console.log(` Session: ${binding.sessionId} (bound, ${binding.evidence.length} evidence entries)`);
|
|
416
|
+
} else {
|
|
417
|
+
console.log(' Session: unbound (Owner report; no trajectory evidence)');
|
|
418
|
+
}
|
|
419
|
+
if (result.admissionResults && result.admissionResults.length > 0) {
|
|
420
|
+
console.log(` Admission: ${admittedCount} of ${result.admissionResults.length} candidates admitted`);
|
|
421
|
+
for (const r of result.admissionResults) {
|
|
422
|
+
console.log(` - ${r.candidateId}: ${r.admission.decision} (${r.admission.reason})`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
220
425
|
if (result.latencyMs !== undefined) console.log(` Latency: ${result.latencyMs}ms`);
|
|
426
|
+
for (const w of cliWarnings) {
|
|
427
|
+
console.warn(` ⚠️ ${w}`);
|
|
428
|
+
}
|
|
221
429
|
console.log(`\nDiagnostician pipeline running. Check progress with:`);
|
|
222
430
|
console.log(` pd task show ${result.taskId} --workspace "${workspaceDir}"`);
|
|
223
431
|
} else if (result.status === 'submitted') {
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
DiagRouterRunner,
|
|
25
25
|
DefaultDiagRootCauseValidator,
|
|
26
26
|
DefaultDiagDistillerValidator,
|
|
27
|
-
DisabledDiagnosticianRunner,
|
|
28
27
|
type DiagnosticianRunnerLike,
|
|
29
28
|
TestDoubleRuntimeAdapter,
|
|
30
29
|
OpenClawCliRuntimeAdapter,
|
|
@@ -33,8 +32,8 @@ import {
|
|
|
33
32
|
isRuntimeConfigError,
|
|
34
33
|
CandidateIntakeService,
|
|
35
34
|
run as diagnoseRun,
|
|
36
|
-
isFeatureEnabled,
|
|
37
35
|
SPLIT_PIPELINE_TOTAL_TIMEOUT_MS,
|
|
36
|
+
resolveDiagnosticianCapability,
|
|
38
37
|
PrincipleTreeLedgerAdapter,
|
|
39
38
|
SqliteDeadLetterStore,
|
|
40
39
|
PainSignalBridge,
|
|
@@ -44,7 +43,7 @@ import {
|
|
|
44
43
|
} from '@principles/core/runtime-v2';
|
|
45
44
|
import type { PDRuntimeAdapter, RuntimeConfig, OutputLanguage } from '@principles/core/runtime-v2';
|
|
46
45
|
import type { Command } from 'commander';
|
|
47
|
-
import { loadPdConfig
|
|
46
|
+
import { loadPdConfig } from '../services/pd-config-loader.js';
|
|
48
47
|
import { resolveRuntimeFromPdConfig } from '../services/resolve-runtime-from-pd-config.js';
|
|
49
48
|
import { createHash } from 'node:crypto';
|
|
50
49
|
/** Layer 0 content-hash (design §6.1); injected so diag writers can attach predecessorSummary hashes. */
|
|
@@ -226,6 +225,9 @@ function refuseExit(opts: PainRetryOptions, payload: { status?: string; painId:
|
|
|
226
225
|
}));
|
|
227
226
|
} else {
|
|
228
227
|
console.error(`error: ${payload.message ?? payload.reason}`);
|
|
228
|
+
// PRI-638: the reason must be visible in text mode too — an Owner kill
|
|
229
|
+
// switch (capability_disabled) must not read like a runtime fault.
|
|
230
|
+
console.error(`reason: ${payload.reason}`);
|
|
229
231
|
console.error(`nextAction: ${payload.nextAction}`);
|
|
230
232
|
}
|
|
231
233
|
process.exit(1);
|
|
@@ -245,6 +247,28 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
|
|
|
245
247
|
|
|
246
248
|
const { taskId } = resolution;
|
|
247
249
|
|
|
250
|
+
// PRI-638: the capability gate comes BEFORE runtime resolution. Previously an
|
|
251
|
+
// Owner-disabled Diagnostician surfaced here as `missing_runtime` ("no
|
|
252
|
+
// .pd/config.yaml runtime binding found"), which told the Owner their config
|
|
253
|
+
// was broken when they had deliberately switched the agent off. This reads the
|
|
254
|
+
// same canonical authority (`internalAgents.agents.diagnostician.enabled`) the
|
|
255
|
+
// runtime factory uses — the CLI owns no kill switch of its own.
|
|
256
|
+
const capability = resolveDiagnosticianCapability(
|
|
257
|
+
(() => {
|
|
258
|
+
const loaded = loadPdConfig(workspaceDir);
|
|
259
|
+
return loaded.ok ? loaded.effective : loaded.defaults;
|
|
260
|
+
})(),
|
|
261
|
+
);
|
|
262
|
+
if (!capability.available) {
|
|
263
|
+
return refuseExit(opts, {
|
|
264
|
+
painId: opts.painId,
|
|
265
|
+
taskId,
|
|
266
|
+
reason: capability.reason,
|
|
267
|
+
message: capability.message,
|
|
268
|
+
nextAction: capability.nextAction,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
248
272
|
// Step 2: Look up task and validate
|
|
249
273
|
const stateManager = new RuntimeStateManager({ workspaceDir });
|
|
250
274
|
|
|
@@ -522,17 +546,18 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
|
|
|
522
546
|
const outputLangResult = readOutputLanguageFromWorkspace(workspaceDir);
|
|
523
547
|
const outputLanguage: OutputLanguage | undefined = outputLangResult.outputLanguage;
|
|
524
548
|
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const pipelineTimeoutMs =
|
|
549
|
+
// PRI-638: implementation selection is gone. The split pipeline is the only
|
|
550
|
+
// Diagnostician implementation, so it always runs with its documented
|
|
551
|
+
// 3-stage budget; `diagnostician_split_pipeline` no longer selects a runner
|
|
552
|
+
// nor disables capability (the capability gate above owns that).
|
|
553
|
+
const pipelineTimeoutMs = SPLIT_PIPELINE_TOTAL_TIMEOUT_MS;
|
|
530
554
|
// BUG-1 (PRI-442): extract effectiveConfig so ADR-0019 LLM rate-limit
|
|
531
555
|
// degradation (isDegradationEnabled in base-peer-runner) can read the
|
|
532
556
|
// diagnostician_llm_degradation feature flag. Without this, the runners
|
|
533
557
|
// receive no effectiveConfig and degradation silently never fires.
|
|
534
558
|
// CR-1 (CodeRabbit P2, rc-9): warn when config load failed so the
|
|
535
559
|
// fallback to defaults is observable — no silent degradation.
|
|
560
|
+
const configLoadResult = loadPdConfig(workspaceDir);
|
|
536
561
|
if (!configLoadResult.ok) {
|
|
537
562
|
const errSummary = configLoadResult.errors
|
|
538
563
|
.map((e) => `${e.path}: ${e.reason}`)
|
|
@@ -543,34 +568,29 @@ export async function handlePainRetry(opts: PainRetryOptions): Promise<void> {
|
|
|
543
568
|
}
|
|
544
569
|
const effectiveConfig = configLoadResult.ok ? configLoadResult.effective : configLoadResult.defaults;
|
|
545
570
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
)
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
perStageTimeoutMs,
|
|
570
|
-
});
|
|
571
|
-
} else {
|
|
572
|
-
runner = new DisabledDiagnosticianRunner();
|
|
573
|
-
}
|
|
571
|
+
const resolvedKind = typeof runtimeAdapter.kind === 'function' ? runtimeAdapter.kind() : runtimeKind;
|
|
572
|
+
const perStageTimeoutMs = pipelineTimeoutMs / 3;
|
|
573
|
+
const rootCauseRunner = new DiagRootCauseRunner(
|
|
574
|
+
{ stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagRootCauseValidator(), contextAssembler, contentHashFn },
|
|
575
|
+
{ owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
|
|
576
|
+
);
|
|
577
|
+
const distillerRunner = new DiagDistillerRunner(
|
|
578
|
+
{ stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDiagDistillerValidator(), contentHashFn },
|
|
579
|
+
{ owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
|
|
580
|
+
);
|
|
581
|
+
const routerRunner = new DiagRouterRunner(
|
|
582
|
+
{ stateManager, runtimeAdapter, eventEmitter, artifactStore: stateManager.piArtifactStore, committer, contentHashFn },
|
|
583
|
+
{ owner: 'pd-cli-pain-retry', runtimeKind: resolvedKind, outputLanguage, timeoutMs: perStageTimeoutMs, effectiveConfig },
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const runner: DiagnosticianRunnerLike = new SplitDiagnosticianRunner({
|
|
587
|
+
rootCauseRunner,
|
|
588
|
+
distillerRunner,
|
|
589
|
+
routerRunner,
|
|
590
|
+
stateManager,
|
|
591
|
+
committer,
|
|
592
|
+
perStageTimeoutMs,
|
|
593
|
+
});
|
|
574
594
|
|
|
575
595
|
// ── Dead letter replay branch ──────────────────────────────────────────
|
|
576
596
|
// When task was null but a dead letter was found, replay the pain signal
|
package/src/index.ts
CHANGED
|
@@ -122,7 +122,7 @@ painCmd
|
|
|
122
122
|
.option('-s, --score <number>', 'Pain score 0-100', parseInt)
|
|
123
123
|
.option('-S, --source <text>', 'Source of the pain signal', 'manual')
|
|
124
124
|
.option('-w, --workspace <path>', 'Workspace directory')
|
|
125
|
-
.option('--session <id>', '
|
|
125
|
+
.option('--session <id>', 'Session ID to bind (validated against this workspace\'s trajectory.db; without it the record is unbound: no trajectory evidence, candidates likely gated by the admission threshold)')
|
|
126
126
|
.option('--wait', 'Wait for diagnosis to complete (sync mode, overrides async flag)')
|
|
127
127
|
.option('--json', 'Output raw JSON')
|
|
128
128
|
.action(async (opts) => {
|
|
@@ -133,6 +133,9 @@ vi.mock('@principles/core/runtime-v2', () => {
|
|
|
133
133
|
OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
|
|
134
134
|
PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
|
|
135
135
|
SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
|
|
136
|
+
// PRI-638: capability gate — available by default so the BDD scenarios
|
|
137
|
+
// exercise the pre-existing success/failure JSON contracts unchanged.
|
|
138
|
+
resolveDiagnosticianCapability: vi.fn(() => ({ available: true })),
|
|
136
139
|
PDRuntimeError: class PDRuntimeError extends Error {
|
|
137
140
|
constructor(public category: string, message: string) {
|
|
138
141
|
super(message);
|