@principles/pd-cli 1.145.0 → 1.145.2
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/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-list.d.ts +45 -0
- package/dist/commands/pain-list.d.ts.map +1 -0
- package/dist/commands/pain-list.js +165 -0
- package/dist/commands/pain-list.js.map +1 -0
- 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/commands/runtime-init.d.ts.map +1 -1
- package/dist/commands/runtime-init.js +12 -1
- package/dist/commands/runtime-init.js.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/diagnose.ts +59 -36
- package/src/commands/pain-list.ts +203 -0
- package/src/commands/pain-retry.ts +56 -36
- package/src/commands/runtime-init.ts +12 -1
- package/src/index.ts +12 -0
- package/tests/bdd/cli-contract.steps.ts +3 -0
- package/tests/commands/diagnose.test.ts +82 -0
- package/tests/commands/pain-list.test.ts +185 -0
- package/tests/commands/pain-retry.test.ts +72 -1
- package/tests/commands/runtime-init.test.ts +20 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd pain list command — PRI-640 (Governance Signal Host Attribution v0.1)
|
|
3
|
+
*
|
|
4
|
+
* Read-only: lists canonical pain_events rows from trajectory.db with their
|
|
5
|
+
* host attribution. `--host` filters by openclaw / codex / unknown.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* pd pain list [--workspace <path>] [--limit N] [--host openclaw|codex|unknown] [--json]
|
|
9
|
+
*
|
|
10
|
+
* Host semantics (SPEC §6/§12): host_kind is observability metadata only.
|
|
11
|
+
* NULL (legacy / manual / unprovable) is reported as `unknown` — never guessed.
|
|
12
|
+
* On a pre-PRI-640 database without the host_kind column every row reports
|
|
13
|
+
* `unknown` and a `host_kind_column_missing` warning is emitted (rc-9).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
19
|
+
|
|
20
|
+
export type PainListHostFilter = 'openclaw' | 'codex' | 'unknown';
|
|
21
|
+
|
|
22
|
+
export interface PainListOptions {
|
|
23
|
+
workspace?: string;
|
|
24
|
+
limit?: number;
|
|
25
|
+
host?: string;
|
|
26
|
+
json?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PainListEntry {
|
|
30
|
+
/** Canonical pain identity; `row:<id>` when the legacy row has none. */
|
|
31
|
+
painId: string;
|
|
32
|
+
source: string;
|
|
33
|
+
/** 'openclaw' | 'codex' | 'unknown' — unknown covers NULL/unprovable. */
|
|
34
|
+
host: PainListHostFilter | 'unknown';
|
|
35
|
+
score: number;
|
|
36
|
+
severity: string | null;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
runtimeTaskId: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PainListResult {
|
|
42
|
+
count: number;
|
|
43
|
+
pains: PainListEntry[];
|
|
44
|
+
workspace: string;
|
|
45
|
+
hostFilter: PainListHostFilter | null;
|
|
46
|
+
warnings: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ownField(row: unknown, key: string): unknown {
|
|
54
|
+
return isRecord(row) && Object.hasOwn(row, key) ? row[key] : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Runtime Contract #1/#4: validate each DB row; skip malformed rows loudly. */
|
|
58
|
+
function toPainEntry(row: unknown): PainListEntry | null {
|
|
59
|
+
const id = ownField(row, 'id');
|
|
60
|
+
const source = ownField(row, 'source');
|
|
61
|
+
const score = ownField(row, 'score');
|
|
62
|
+
const createdAt = ownField(row, 'created_at');
|
|
63
|
+
if (typeof id !== 'number' || typeof source !== 'string' || typeof score !== 'number' || typeof createdAt !== 'string') {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const canonicalPainId = ownField(row, 'canonical_pain_id');
|
|
67
|
+
const hostKind = ownField(row, 'host_kind');
|
|
68
|
+
const severity = ownField(row, 'severity');
|
|
69
|
+
const runtimeTaskId = ownField(row, 'runtime_task_id');
|
|
70
|
+
const host: PainListEntry['host'] =
|
|
71
|
+
hostKind === 'openclaw' || hostKind === 'codex' ? hostKind : 'unknown';
|
|
72
|
+
return {
|
|
73
|
+
painId: typeof canonicalPainId === 'string' && canonicalPainId.length > 0 ? canonicalPainId : `row:${id}`,
|
|
74
|
+
source,
|
|
75
|
+
host,
|
|
76
|
+
score,
|
|
77
|
+
severity: typeof severity === 'string' ? severity : null,
|
|
78
|
+
createdAt,
|
|
79
|
+
runtimeTaskId: typeof runtimeTaskId === 'string' && runtimeTaskId.length > 0 ? runtimeTaskId : null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function listPains(dbPath: string, options: { limit?: number; host?: PainListHostFilter } = {}): Promise<PainListResult> {
|
|
84
|
+
const Database = (await import('better-sqlite3')).default;
|
|
85
|
+
const db = new Database(dbPath, { readonly: true });
|
|
86
|
+
try {
|
|
87
|
+
const warnings: string[] = [];
|
|
88
|
+
const columns: unknown[] = db.prepare('PRAGMA table_info(pain_events)').all();
|
|
89
|
+
const hasHostKindColumn = columns.some((row) => ownField(row, 'name') === 'host_kind');
|
|
90
|
+
if (!hasHostKindColumn) {
|
|
91
|
+
warnings.push('host_kind_column_missing');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const params: (string | number)[] = [];
|
|
95
|
+
let query = 'SELECT id, source, score, severity, created_at, canonical_pain_id, runtime_task_id';
|
|
96
|
+
query += hasHostKindColumn ? ', host_kind' : ", NULL AS host_kind";
|
|
97
|
+
query += ' FROM pain_events WHERE 1=1';
|
|
98
|
+
if (options.host === 'unknown') {
|
|
99
|
+
query += hasHostKindColumn ? ' AND host_kind IS NULL' : '';
|
|
100
|
+
} else if (options.host === 'openclaw' || options.host === 'codex') {
|
|
101
|
+
if (!hasHostKindColumn) {
|
|
102
|
+
return { count: 0, pains: [], workspace: path.dirname(path.dirname(dbPath)), hostFilter: options.host, warnings };
|
|
103
|
+
}
|
|
104
|
+
query += ' AND host_kind = ?';
|
|
105
|
+
params.push(options.host);
|
|
106
|
+
}
|
|
107
|
+
query += ' ORDER BY created_at DESC, id DESC';
|
|
108
|
+
const limit = options.limit ?? 20;
|
|
109
|
+
query += ' LIMIT ?';
|
|
110
|
+
params.push(limit);
|
|
111
|
+
|
|
112
|
+
const rawRows: unknown[] = db.prepare(query).all(...params);
|
|
113
|
+
const skipped = rawRows.length - rawRows.map(toPainEntry).filter((entry) => entry !== null).length;
|
|
114
|
+
if (skipped > 0) {
|
|
115
|
+
warnings.push(`malformed_rows_skipped:${skipped}`);
|
|
116
|
+
}
|
|
117
|
+
const pains = rawRows
|
|
118
|
+
.map(toPainEntry)
|
|
119
|
+
.filter((entry): entry is PainListEntry => entry !== null);
|
|
120
|
+
return {
|
|
121
|
+
count: pains.length,
|
|
122
|
+
pains,
|
|
123
|
+
workspace: path.dirname(path.dirname(dbPath)),
|
|
124
|
+
hostFilter: options.host ?? null,
|
|
125
|
+
warnings,
|
|
126
|
+
};
|
|
127
|
+
} finally {
|
|
128
|
+
db.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function printHuman(result: PainListResult): void {
|
|
133
|
+
const filterNote = result.hostFilter ? ` (host: ${result.hostFilter})` : '';
|
|
134
|
+
console.log(`Pain events — ${result.count} shown${filterNote}`);
|
|
135
|
+
console.log('─'.repeat(96));
|
|
136
|
+
for (const pain of result.pains) {
|
|
137
|
+
const painId = pain.painId.length > 40 ? `${pain.painId.slice(0, 37)}...` : pain.painId;
|
|
138
|
+
console.log(` ${pain.createdAt} host=${pain.host.padEnd(8)} score=${String(pain.score).padEnd(3)} ${pain.severity ?? '-'.padEnd(6)} ${pain.source}`);
|
|
139
|
+
console.log(` id: ${painId}${pain.runtimeTaskId ? ` | task: ${pain.runtimeTaskId}` : ''}`);
|
|
140
|
+
}
|
|
141
|
+
console.log('─'.repeat(96));
|
|
142
|
+
for (const warning of result.warnings) {
|
|
143
|
+
console.error(`WARN: ${warning}`);
|
|
144
|
+
}
|
|
145
|
+
if (result.warnings.includes('host_kind_column_missing')) {
|
|
146
|
+
console.error('Next: run `pd runtime init --workspace <dir>` (or restart the OpenClaw plugin) to migrate this workspace schema.');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function handlePainList(opts: PainListOptions): Promise<void> {
|
|
151
|
+
const { workspace, limit: rawLimit, host: rawHost, json } = opts;
|
|
152
|
+
|
|
153
|
+
if (rawHost !== undefined && rawHost !== 'openclaw' && rawHost !== 'codex' && rawHost !== 'unknown') {
|
|
154
|
+
const message = `invalid host filter: expected openclaw | codex | unknown, got ${rawHost}`;
|
|
155
|
+
if (json) {
|
|
156
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'invalid_host_filter', message, nextAction: 'Pass --host openclaw, --host codex, or --host unknown.' }));
|
|
157
|
+
} else {
|
|
158
|
+
console.error('Error: ' + message);
|
|
159
|
+
console.error('Next: Pass --host openclaw, --host codex, or --host unknown.');
|
|
160
|
+
}
|
|
161
|
+
process.exit(1);
|
|
162
|
+
return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
|
|
163
|
+
}
|
|
164
|
+
const hostFilter: PainListHostFilter | undefined = rawHost;
|
|
165
|
+
|
|
166
|
+
let effectiveLimit = 20;
|
|
167
|
+
if (rawLimit !== undefined) {
|
|
168
|
+
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > 10000) {
|
|
169
|
+
const message = `invalid limit: limit must be an integer between 1 and 10000, got ${rawLimit}`;
|
|
170
|
+
if (json) {
|
|
171
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'invalid_limit', message, nextAction: 'Pass --limit with a valid integer (1-10000).' }));
|
|
172
|
+
} else {
|
|
173
|
+
console.error('Error: ' + message);
|
|
174
|
+
console.error('Next: Pass --limit with a valid integer (1-10000).');
|
|
175
|
+
}
|
|
176
|
+
process.exit(1);
|
|
177
|
+
return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
|
|
178
|
+
}
|
|
179
|
+
effectiveLimit = rawLimit;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const workspaceDir = resolveWorkspaceDir(workspace);
|
|
183
|
+
const dbPath = path.join(workspaceDir, '.state', 'trajectory.db');
|
|
184
|
+
if (!fs.existsSync(dbPath)) {
|
|
185
|
+
const message = `trajectory database not found at ${dbPath}`;
|
|
186
|
+
if (json) {
|
|
187
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'trajectory_db_not_found', message, nextAction: 'Initialize the PD workspace first (pd runtime init), or pass --workspace pointing at an initialized workspace.' }));
|
|
188
|
+
} else {
|
|
189
|
+
console.error('Error: ' + message);
|
|
190
|
+
console.error('Next: Initialize the PD workspace first (pd runtime init), or pass --workspace pointing at an initialized workspace.');
|
|
191
|
+
}
|
|
192
|
+
process.exit(1);
|
|
193
|
+
return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = await listPains(dbPath, { limit: effectiveLimit, ...(hostFilter ? { host: hostFilter } : {}) });
|
|
197
|
+
|
|
198
|
+
if (json) {
|
|
199
|
+
console.log(JSON.stringify(result, null, 2));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
printHuman(result);
|
|
203
|
+
}
|
|
@@ -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
|
|
@@ -95,12 +95,23 @@ const DB_NAMES = {
|
|
|
95
95
|
*/
|
|
96
96
|
function buildConfigYaml(workspaceDir: string): string {
|
|
97
97
|
const config = getDefaultPdConfig();
|
|
98
|
+
// PRI-637: `pd runtime init` is PD machinery writing a bootstrap snapshot, so
|
|
99
|
+
// every flag entry records `source: 'system'` — an ORIGIN HINT, NOT proof that
|
|
100
|
+
// the value lacks Owner intent (direct .pd/config.yaml editing is a supported
|
|
101
|
+
// path; an Owner may later pin behavior on top of this entry). Labels are
|
|
102
|
+
// metadata only; effective values are unchanged. If a flag graduates, system-
|
|
103
|
+
// origin entries are cleanup CANDIDATES that still require explicit Owner
|
|
104
|
+
// confirmation before removal.
|
|
105
|
+
const features: Record<string, unknown> = {};
|
|
106
|
+
for (const [id, entry] of Object.entries(config.features)) {
|
|
107
|
+
features[id] = { category: entry.category, enabled: entry.enabled, source: 'system' };
|
|
108
|
+
}
|
|
98
109
|
const yamlObj: Record<string, unknown> = {
|
|
99
110
|
version: config.version,
|
|
100
111
|
workspace: {
|
|
101
112
|
default: workspaceDir,
|
|
102
113
|
},
|
|
103
|
-
features
|
|
114
|
+
features,
|
|
104
115
|
runtimeProfiles: config.runtimeProfiles,
|
|
105
116
|
internalAgents: config.internalAgents,
|
|
106
117
|
ui: config.ui,
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { Command } from 'commander';
|
|
|
10
10
|
import { handlePainRecord } from './commands/pain-record.js';
|
|
11
11
|
import { registerPainRetryCommand } from './commands/pain-retry.js';
|
|
12
12
|
import { handlePainEvidence } from './commands/pain-evidence.js';
|
|
13
|
+
import { handlePainList } from './commands/pain-list.js';
|
|
13
14
|
import { handleSamplesList } from './commands/samples-list.js';
|
|
14
15
|
import { handleSamplesReview } from './commands/samples-review.js';
|
|
15
16
|
import { handleEvolutionTasksList } from './commands/evolution-tasks-list.js';
|
|
@@ -130,6 +131,17 @@ painCmd
|
|
|
130
131
|
|
|
131
132
|
registerPainRetryCommand(painCmd);
|
|
132
133
|
|
|
134
|
+
painCmd
|
|
135
|
+
.command('list')
|
|
136
|
+
.description('List canonical pain events with host attribution (PRI-640)')
|
|
137
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
138
|
+
.option('-l, --limit <number>', 'Max entries to show (default: 20)', parseInt)
|
|
139
|
+
.option('--host <kind>', 'Filter by host: openclaw | codex | unknown')
|
|
140
|
+
.option('--json', 'Output raw JSON')
|
|
141
|
+
.action(async (opts) => {
|
|
142
|
+
await handlePainList(opts);
|
|
143
|
+
});
|
|
144
|
+
|
|
133
145
|
painCmd
|
|
134
146
|
.command('evidence')
|
|
135
147
|
.description('Show recent TRIGGER_DECISION log entries — pain admission gate decisions only (PEAT-B2)')
|
|
@@ -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);
|
|
@@ -108,6 +108,8 @@ vi.mock('@principles/core/runtime-v2', () => {
|
|
|
108
108
|
OpenClawCliRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
|
|
109
109
|
PiAiRuntimeAdapter: vi.fn().mockImplementation(function () { return {}; }),
|
|
110
110
|
SPLIT_PIPELINE_TOTAL_TIMEOUT_MS: 300000,
|
|
111
|
+
// PRI-638: capability gate — available by default; disabled cases override this.
|
|
112
|
+
resolveDiagnosticianCapability: vi.fn((): { available: boolean; reason?: string; message?: string; nextAction?: string } => ({ available: true })),
|
|
111
113
|
PDRuntimeError: class PDRuntimeError extends Error {
|
|
112
114
|
constructor(public category: string, message: string) {
|
|
113
115
|
super(message);
|
|
@@ -1714,3 +1716,83 @@ describe('BUG-2 (PRI-442): sourcePainId resolution for dreamer seed', () => {
|
|
|
1714
1716
|
exitSpy.mockRestore();
|
|
1715
1717
|
});
|
|
1716
1718
|
});
|
|
1719
|
+
|
|
1720
|
+
// ── PRI-638: unified capability-disabled semantics ───────────────────────────
|
|
1721
|
+
//
|
|
1722
|
+
// The CLI owns no kill switch of its own: it reads the canonical authority
|
|
1723
|
+
// (internalAgents.agents.diagnostician.enabled) through the same resolver the
|
|
1724
|
+
// runtime factory uses. Owner-disabled must come out as a structured
|
|
1725
|
+
// `capability_disabled` result — never as missing_runtime / config failure —
|
|
1726
|
+
// with no adapter constructed and no provider contacted.
|
|
1727
|
+
|
|
1728
|
+
describe('PRI-638: pd diagnose run when Diagnostician capability is disabled', () => {
|
|
1729
|
+
beforeEach(async () => {
|
|
1730
|
+
vi.clearAllMocks();
|
|
1731
|
+
const runtimeV2 = await import('@principles/core/runtime-v2');
|
|
1732
|
+
vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReturnValue({
|
|
1733
|
+
available: false,
|
|
1734
|
+
reason: 'capability_disabled',
|
|
1735
|
+
message: "Agent 'diagnostician' is disabled",
|
|
1736
|
+
nextAction: "Enable agent 'diagnostician' in .pd/config.yaml internalAgents.agents.diagnostician.enabled",
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
afterEach(async () => {
|
|
1741
|
+
const runtimeV2 = await import('@principles/core/runtime-v2');
|
|
1742
|
+
vi.mocked(runtimeV2.resolveDiagnosticianCapability).mockReset();
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
it('DIAG-638-01: --json emits a structured capability_disabled result and exits 1', async () => {
|
|
1746
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1747
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
1748
|
+
|
|
1749
|
+
await handleDiagnoseRun({
|
|
1750
|
+
taskId: 'diag_task-1',
|
|
1751
|
+
workspace: '/tmp/fake-workspace',
|
|
1752
|
+
runtime: 'test-double',
|
|
1753
|
+
json: true,
|
|
1754
|
+
} as DiagnoseRunOptions);
|
|
1755
|
+
|
|
1756
|
+
const jsonLine = logSpy.mock.calls
|
|
1757
|
+
.map((c) => String(c[0]))
|
|
1758
|
+
.find((line) => line.trim().startsWith('{'));
|
|
1759
|
+
expect(jsonLine).toBeDefined();
|
|
1760
|
+
const parsed = JSON.parse(jsonLine as string);
|
|
1761
|
+
expect(parsed.reason).toBe('capability_disabled');
|
|
1762
|
+
expect(parsed.nextAction).toContain('internalAgents.agents.diagnostician.enabled');
|
|
1763
|
+
expect(parsed.message).toContain('disabled');
|
|
1764
|
+
|
|
1765
|
+
// Kill switch fires before any runtime machinery: no adapter, no runner.
|
|
1766
|
+
const runtimeV2 = await import('@principles/core/runtime-v2');
|
|
1767
|
+
expect(runtimeV2.TestDoubleRuntimeAdapter).not.toHaveBeenCalled();
|
|
1768
|
+
expect(runtimeV2.SplitDiagnosticianRunner).not.toHaveBeenCalled();
|
|
1769
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1770
|
+
|
|
1771
|
+
logSpy.mockRestore();
|
|
1772
|
+
exitSpy.mockRestore();
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it('DIAG-638-02: human-readable output names the reason and the recovery action', async () => {
|
|
1776
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
1777
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
|
|
1778
|
+
|
|
1779
|
+
await handleDiagnoseRun({
|
|
1780
|
+
taskId: 'diag_task-1',
|
|
1781
|
+
workspace: '/tmp/fake-workspace',
|
|
1782
|
+
runtime: 'test-double',
|
|
1783
|
+
json: false,
|
|
1784
|
+
} as DiagnoseRunOptions);
|
|
1785
|
+
|
|
1786
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
1787
|
+
expect(out).toContain('diagnostician');
|
|
1788
|
+
expect(out).toContain('capability_disabled');
|
|
1789
|
+
expect(out).toContain('internalAgents.agents.diagnostician.enabled');
|
|
1790
|
+
|
|
1791
|
+
const runtimeV2b = await import('@principles/core/runtime-v2');
|
|
1792
|
+
expect(runtimeV2b.TestDoubleRuntimeAdapter).not.toHaveBeenCalled();
|
|
1793
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
1794
|
+
|
|
1795
|
+
errSpy.mockRestore();
|
|
1796
|
+
exitSpy.mockRestore();
|
|
1797
|
+
});
|
|
1798
|
+
});
|