@principles/pd-cli 1.147.16 → 1.148.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/pain-list.d.ts +14 -0
- package/dist/commands/pain-list.d.ts.map +1 -1
- package/dist/commands/pain-list.js +28 -1
- package/dist/commands/pain-list.js.map +1 -1
- package/dist/commands/pain-record.d.ts +2 -0
- package/dist/commands/pain-record.d.ts.map +1 -1
- package/dist/commands/pain-record.js +71 -4
- package/dist/commands/pain-record.js.map +1 -1
- package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-once.js +23 -7
- package/dist/commands/runtime-internalization-run-once.js.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/services/__tests__/runtime-adapter-resolver.test.js +4 -1
- package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -1
- package/dist/services/resolve-runtime-from-pd-config.d.ts +28 -2
- package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -1
- package/dist/services/resolve-runtime-from-pd-config.js +19 -6
- package/dist/services/resolve-runtime-from-pd-config.js.map +1 -1
- package/dist/services/rulehost-readiness.js +1 -1
- package/dist/services/rulehost-readiness.js.map +1 -1
- package/dist/services/runtime-adapter-resolver.d.ts +14 -1
- package/dist/services/runtime-adapter-resolver.d.ts.map +1 -1
- package/dist/services/runtime-adapter-resolver.js +4 -1
- package/dist/services/runtime-adapter-resolver.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/pain-list.ts +37 -1
- package/src/commands/pain-record.ts +73 -4
- package/src/commands/runtime-internalization-run-once.ts +23 -7
- package/src/index.ts +1 -0
- package/src/services/__tests__/runtime-adapter-resolver.test.ts +4 -1
- package/src/services/resolve-runtime-from-pd-config.ts +44 -6
- package/src/services/rulehost-readiness.ts +1 -1
- package/src/services/runtime-adapter-resolver.ts +18 -2
- package/tests/commands/pain-list.test.ts +35 -1
- package/tests/commands/pain-record-session-parser.test.ts +27 -0
- package/tests/commands/pain-record.test.ts +77 -0
- package/tests/commands/pri-393-runtime-config-unification.test.ts +13 -11
- package/tests/commands/runtime-internalization-run-once-language.test.ts +12 -0
- package/tests/commands/runtime-internalization-run-once-real-config.test.ts +237 -0
- package/tests/commands/runtime-internalization-run-once.test.ts +21 -5
- package/tests/services/resolve-runtime-from-pd-config.test.ts +3 -3
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Read-only: lists canonical pain_events rows from trajectory.db with their
|
|
5
5
|
* host attribution. `--host` filters by openclaw / codex / unknown.
|
|
6
|
+
* PRI-743: the result (human and --json) also carries `byHost`, the
|
|
7
|
+
* full-table host distribution independent of --host/--limit.
|
|
6
8
|
*
|
|
7
9
|
* Usage:
|
|
8
10
|
* pd pain list [--workspace <path>] [--limit N] [--host openclaw|codex|unknown] [--json]
|
|
@@ -43,6 +45,14 @@ export interface PainListResult {
|
|
|
43
45
|
pains: PainListEntry[];
|
|
44
46
|
workspace: string;
|
|
45
47
|
hostFilter: PainListHostFilter | null;
|
|
48
|
+
/**
|
|
49
|
+
* PRI-743: full-table host distribution (independent of --limit and --host
|
|
50
|
+
* filtering) so operator tooling can group by host without an external
|
|
51
|
+
* script. `null` only on a pre-PRI-640 database without the host_kind
|
|
52
|
+
* column — the distribution is unprovable there, never guessed (rc-3/rc-9;
|
|
53
|
+
* see the accompanying host_kind_column_missing warning).
|
|
54
|
+
*/
|
|
55
|
+
byHost: { openclaw: number; codex: number; unknown: number } | null;
|
|
46
56
|
warnings: string[];
|
|
47
57
|
}
|
|
48
58
|
|
|
@@ -91,6 +101,27 @@ export async function listPains(dbPath: string, options: { limit?: number; host?
|
|
|
91
101
|
warnings.push('host_kind_column_missing');
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
// PRI-743: full-table distribution, deliberately NOT narrowed by the
|
|
105
|
+
// --host filter or --limit below — the operator question this answers is
|
|
106
|
+
// "how do pains split across hosts in this workspace", which a filtered
|
|
107
|
+
// count cannot answer. Non-openclaw/codex values (incl. NULL) count as
|
|
108
|
+
// unknown, mirroring toPainEntry's read-side normalization.
|
|
109
|
+
const byHost: PainListResult['byHost'] = hasHostKindColumn
|
|
110
|
+
? { openclaw: 0, codex: 0, unknown: 0 }
|
|
111
|
+
: null;
|
|
112
|
+
if (byHost) {
|
|
113
|
+
const distRows: unknown[] = db
|
|
114
|
+
.prepare("SELECT COALESCE(host_kind, 'unknown') AS host_bucket, COUNT(*) AS n FROM pain_events GROUP BY host_kind")
|
|
115
|
+
.all();
|
|
116
|
+
for (const row of distRows) {
|
|
117
|
+
const bucket = ownField(row, 'host_bucket');
|
|
118
|
+
const n = ownField(row, 'n');
|
|
119
|
+
if ((bucket === 'openclaw' || bucket === 'codex' || bucket === 'unknown') && typeof n === 'number') {
|
|
120
|
+
byHost[bucket] += n;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
const params: (string | number)[] = [];
|
|
95
126
|
let query = 'SELECT id, source, score, severity, created_at, canonical_pain_id, runtime_task_id';
|
|
96
127
|
query += hasHostKindColumn ? ', host_kind' : ", NULL AS host_kind";
|
|
@@ -99,7 +130,7 @@ export async function listPains(dbPath: string, options: { limit?: number; host?
|
|
|
99
130
|
query += hasHostKindColumn ? ' AND host_kind IS NULL' : '';
|
|
100
131
|
} else if (options.host === 'openclaw' || options.host === 'codex') {
|
|
101
132
|
if (!hasHostKindColumn) {
|
|
102
|
-
return { count: 0, pains: [], workspace: path.dirname(path.dirname(dbPath)), hostFilter: options.host, warnings };
|
|
133
|
+
return { count: 0, pains: [], workspace: path.dirname(path.dirname(dbPath)), hostFilter: options.host, byHost, warnings };
|
|
103
134
|
}
|
|
104
135
|
query += ' AND host_kind = ?';
|
|
105
136
|
params.push(options.host);
|
|
@@ -122,6 +153,7 @@ export async function listPains(dbPath: string, options: { limit?: number; host?
|
|
|
122
153
|
pains,
|
|
123
154
|
workspace: path.dirname(path.dirname(dbPath)),
|
|
124
155
|
hostFilter: options.host ?? null,
|
|
156
|
+
byHost,
|
|
125
157
|
warnings,
|
|
126
158
|
};
|
|
127
159
|
} finally {
|
|
@@ -132,6 +164,10 @@ export async function listPains(dbPath: string, options: { limit?: number; host?
|
|
|
132
164
|
function printHuman(result: PainListResult): void {
|
|
133
165
|
const filterNote = result.hostFilter ? ` (host: ${result.hostFilter})` : '';
|
|
134
166
|
console.log(`Pain events — ${result.count} shown${filterNote}`);
|
|
167
|
+
if (result.byHost) {
|
|
168
|
+
const { openclaw, codex, unknown } = result.byHost;
|
|
169
|
+
console.log(`By host: openclaw=${openclaw} codex=${codex} unknown=${unknown}`);
|
|
170
|
+
}
|
|
135
171
|
console.log('─'.repeat(96));
|
|
136
172
|
for (const pain of result.pains) {
|
|
137
173
|
const painId = pain.painId.length > 40 ? `${pain.painId.slice(0, 37)}...` : pain.painId;
|
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
* workspace's trajectory.db, and collect raw evidence entries.
|
|
12
12
|
*
|
|
13
13
|
* Usage:
|
|
14
|
-
* pd pain record --reason <text> [--score N] [--source manual] [--workspace <path>] [--session <id>] [--json]
|
|
14
|
+
* pd pain record --reason <text> [--score N] [--source manual] [--workspace <path>] [--session <id>] [--host openclaw|codex] [--json]
|
|
15
|
+
*
|
|
16
|
+
* PRI-743: `--host` makes host attribution explicit instead of assumed.
|
|
17
|
+
* `--host openclaw` records the default attribution without the disclosure
|
|
18
|
+
* warning; `--host codex` refuses loudly — the CLI can verify OpenClaw
|
|
19
|
+
* trajectory sessions but can never verify a Codex lineage tuple
|
|
20
|
+
* (rolloutIdentity / logicalObservationKey, rc-6), so codex pains must come
|
|
21
|
+
* from the Codex ingestion path, never from the CLI. The correlation schema
|
|
22
|
+
* itself is NOT changed — PainIngressCorrelationV1 bound branches only admit
|
|
23
|
+
* openclaw/codex.
|
|
15
24
|
*/
|
|
16
25
|
import {
|
|
17
26
|
PainToPrincipleService,
|
|
@@ -35,6 +44,8 @@ interface RecordOptions {
|
|
|
35
44
|
json?: boolean;
|
|
36
45
|
session?: string;
|
|
37
46
|
wait?: boolean;
|
|
47
|
+
/** PRI-743: explicit host attribution for a bound --session pain. */
|
|
48
|
+
host?: string;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
function emitSessionBindingFailure(
|
|
@@ -135,10 +146,16 @@ function resolveIngressDecision(
|
|
|
135
146
|
|
|
136
147
|
// Available evidence: a real session with real entries — submit, bound.
|
|
137
148
|
if (acquisition.status === 'available') {
|
|
149
|
+
// PRI-743: host attribution is explicit when --host openclaw is given;
|
|
150
|
+
// absent --host keeps the pre-PRI-743 'openclaw' default (validated and
|
|
151
|
+
// disclosed by handlePainRecord — rc-9). --host codex never reaches this
|
|
152
|
+
// point: it is refused before any mutation (no verifiable lineage), so
|
|
153
|
+
// the bound correlation below is always the openclaw shape.
|
|
154
|
+
const hostKind = 'openclaw' as const;
|
|
138
155
|
const decision = evaluatePainIngress({
|
|
139
156
|
...base,
|
|
140
157
|
origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
|
|
141
|
-
correlation: { status: 'bound', hostKind
|
|
158
|
+
correlation: { status: 'bound', hostKind, sessionId: opts.session },
|
|
142
159
|
evidence: { status: 'available', entries: acquisition.entries.map(toIngressEntry) as [IngressEvidenceEntry, ...IngressEvidenceEntry[]] },
|
|
143
160
|
});
|
|
144
161
|
return { decision, acquisitionDetail: null, acquisitionReason: null };
|
|
@@ -157,9 +174,48 @@ function resolveIngressDecision(
|
|
|
157
174
|
}
|
|
158
175
|
|
|
159
176
|
export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
177
|
+
// PRI-743: fail loud on an unknown --host before any mutation (cli-5/cli-6).
|
|
178
|
+
if (opts.host !== undefined && opts.host !== 'openclaw' && opts.host !== 'codex') {
|
|
179
|
+
const message = `invalid --host: expected openclaw | codex, got ${opts.host}`;
|
|
180
|
+
if (opts.json) {
|
|
181
|
+
console.log(JSON.stringify({
|
|
182
|
+
status: 'failed',
|
|
183
|
+
reason: 'invalid_host_kind',
|
|
184
|
+
message,
|
|
185
|
+
nextAction: 'Pass --host openclaw or --host codex, or omit --host (defaults to openclaw).',
|
|
186
|
+
}, null, 2));
|
|
187
|
+
} else {
|
|
188
|
+
console.error(`Error: ${message}`);
|
|
189
|
+
console.error('Next action: pass --host openclaw or --host codex, or omit --host (defaults to openclaw).');
|
|
190
|
+
}
|
|
191
|
+
process.exit(1);
|
|
192
|
+
return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// PRI-743: a CLI-claimed codex attribution would be a PRI-642 recurrence in
|
|
196
|
+
// another shape — the shared ingress evaluator requires the rollout lineage
|
|
197
|
+
// tuple (rc-6) that only the Codex ingestion path can produce, and the CLI
|
|
198
|
+
// owns no Codex identity. Refuse before any mutation instead of faking it.
|
|
199
|
+
if (opts.host === 'codex') {
|
|
200
|
+
const message = 'the CLI cannot verify Codex lineage (rolloutIdentity / logicalObservationKey) — codex pains are produced by the Codex ingestion path, not by pd pain record';
|
|
201
|
+
if (opts.json) {
|
|
202
|
+
console.log(JSON.stringify({
|
|
203
|
+
status: 'failed',
|
|
204
|
+
reason: 'codex_lineage_unverifiable_by_cli',
|
|
205
|
+
message,
|
|
206
|
+
nextAction: 'Record Codex pains through the Codex ingestion path (pd codex setup enables it), or use --host openclaw for OpenClaw-attributed sessions.',
|
|
207
|
+
}, null, 2));
|
|
208
|
+
} else {
|
|
209
|
+
console.error(`Error: ${message}`);
|
|
210
|
+
console.error('Next action: record Codex pains through the Codex ingestion path (pd codex setup enables it), or use --host openclaw for OpenClaw-attributed sessions.');
|
|
211
|
+
}
|
|
212
|
+
process.exit(1);
|
|
213
|
+
return; // guard: test stubs of process.exit continue execution (cli-2-exit-stops)
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
if (!opts.reason) {
|
|
161
217
|
console.error('Error: --reason <text> is required');
|
|
162
|
-
console.error('Usage: pd pain record --reason <text> [--score N] [--source manual] [--workspace <path>] [--session <id>] [--json]');
|
|
218
|
+
console.error('Usage: pd pain record --reason <text> [--score N] [--source manual] [--workspace <path>] [--session <id>] [--host openclaw|codex] [--json]');
|
|
163
219
|
process.exit(1);
|
|
164
220
|
return;
|
|
165
221
|
}
|
|
@@ -363,8 +419,21 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
|
363
419
|
: [];
|
|
364
420
|
const cliWarnings = [...decisionWarnings, gatedWarning].filter((w): w is string => w !== null && w !== undefined);
|
|
365
421
|
|
|
422
|
+
// PRI-743: the pre-existing 'openclaw' default is an assumption, not
|
|
423
|
+
// evidence — disclose it whenever it actually applies (rc-9), so host
|
|
424
|
+
// attribution can never silently distort PRI-743-style host comparisons.
|
|
425
|
+
const hostDefaultWarning = binding.sessionId !== undefined && opts.host === undefined
|
|
426
|
+
? `host attribution defaulted to 'openclaw' (--host not given); pass --host codex to attribute this pain to the Codex host.`
|
|
427
|
+
: null;
|
|
428
|
+
if (hostDefaultWarning !== null) cliWarnings.push(hostDefaultWarning);
|
|
429
|
+
|
|
366
430
|
if (opts.json) {
|
|
367
431
|
const out: Record<string, unknown> = { ...result };
|
|
432
|
+
// PRI-743: surface the effective host attribution explicitly in --json
|
|
433
|
+
// (machine consumers doing host comparisons must not re-derive it).
|
|
434
|
+
if (binding.sessionId !== undefined) {
|
|
435
|
+
out.hostAttribution = binding.hostKind;
|
|
436
|
+
}
|
|
368
437
|
// Ensure nextAction is present for actionable states
|
|
369
438
|
if (out.status === 'submitted') {
|
|
370
439
|
if (!out.nextAction) {
|
|
@@ -412,7 +481,7 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
|
|
|
412
481
|
console.log(` Source: ${opts.source ?? 'manual'}`);
|
|
413
482
|
console.log(` Workspace: ${workspaceDir}`);
|
|
414
483
|
if (binding.sessionId) {
|
|
415
|
-
console.log(` Session: ${binding.sessionId} (bound, ${binding.evidence.length} evidence entries)`);
|
|
484
|
+
console.log(` Session: ${binding.sessionId} (bound, host=${binding.hostKind}, ${binding.evidence.length} evidence entries)`);
|
|
416
485
|
} else {
|
|
417
486
|
console.log(' Session: unbound (Owner report; no trajectory evidence)');
|
|
418
487
|
}
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
TestDoubleRuntimeAdapter,
|
|
19
19
|
} from '@principles/core/runtime-v2';
|
|
20
20
|
import type { WakeOnceResult, DreamerRunnerResult, PhilosopherRunnerResult, ScribeRunnerResult, ArtificerRunnerResult, EvaluatorRunnerResult, RolloutReviewerRunnerResult, PDRuntimeAdapter, PeerRunnerKind, OutputLanguage } from '@principles/core/runtime-v2';
|
|
21
|
-
import {
|
|
21
|
+
import { resolveRuntimeConfigForAgent, AGENT_NAME_FOR_TASK_KIND, isRuntimeConfigError } from '@principles/core/runtime-v2';
|
|
22
22
|
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
23
23
|
import { readOutputLanguageFromWorkspace } from '../config-reader.js';
|
|
24
24
|
import { loadPdConfig } from '../services/pd-config-loader.js';
|
|
@@ -501,13 +501,24 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
|
|
|
501
501
|
const outputLangResult = readOutputLanguageFromWorkspace(workspaceDir);
|
|
502
502
|
const outputLanguage: OutputLanguage | undefined = outputLangResult.outputLanguage;
|
|
503
503
|
|
|
504
|
-
// PRI-670: profile timeout fallback for the runner deadline.
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
504
|
+
// PRI-670: profile timeout fallback for the runner deadline. PRI-719: the
|
|
505
|
+
// profile resolves for the SELECTED RUNNER's agent binding — the shared
|
|
506
|
+
// resolver used to read the diagnostician binding for every stage, silently
|
|
507
|
+
// ignoring per-agent runtimeProfile declarations (EP002-R2 F4). On any
|
|
508
|
+
// resolution error, fall back to the 300s default rather than refusing the
|
|
509
|
+
// run: the flag-off/legacy behavior was 300s.
|
|
510
|
+
// PRI-719 review: explicit run-once is a PEER execution path — its scope
|
|
511
|
+
// is the internalization_full_chain flag, not agents[kind].enabled (the
|
|
512
|
+
// shipped default disables evaluator/rolloutReviewer, which must not block
|
|
513
|
+
// an explicit stage run). Same ignoreAgentEnabled semantics as the
|
|
514
|
+
// auto-consumer; `enabled` keeps gating the diagnostician bridge.
|
|
515
|
+
const runnerAgentName = AGENT_NAME_FOR_TASK_KIND[runnerKind];
|
|
508
516
|
let profileTimeoutMs: number | undefined;
|
|
509
|
-
if (effectiveConfig) {
|
|
510
|
-
const resolved =
|
|
517
|
+
if (effectiveConfig && runnerAgentName !== undefined) {
|
|
518
|
+
const resolved = resolveRuntimeConfigForAgent(effectiveConfig, runnerAgentName, {
|
|
519
|
+
getEnvVar: (name) => process.env[name],
|
|
520
|
+
ignoreAgentEnabled: true,
|
|
521
|
+
});
|
|
511
522
|
if (!isRuntimeConfigError(resolved)) {
|
|
512
523
|
profileTimeoutMs = resolved.timeoutMs;
|
|
513
524
|
}
|
|
@@ -544,6 +555,11 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
|
|
|
544
555
|
runtimeKind,
|
|
545
556
|
workspaceDir,
|
|
546
557
|
runnerKind,
|
|
558
|
+
// PRI-719: resolve the SELECTED runner's own agent binding so its
|
|
559
|
+
// declared runtimeProfile governs the adapter — with the SAME
|
|
560
|
+
// ignoreAgentEnabled peer semantics as the timeout resolution above
|
|
561
|
+
// (declared profile == timeout profile == adapter profile).
|
|
562
|
+
...(runnerAgentName !== undefined ? { agentName: runnerAgentName, ignoreAgentEnabled: true } : {}),
|
|
547
563
|
timeoutMs: cliTimeoutMs,
|
|
548
564
|
allowTestDouble: true,
|
|
549
565
|
testDoublePayloadBuilder: () => buildTestDoubleAdapter(runnerKind, wakeResult.taskId),
|
package/src/index.ts
CHANGED
|
@@ -123,6 +123,7 @@ painCmd
|
|
|
123
123
|
.option('-S, --source <text>', 'Source of the pain signal', 'manual')
|
|
124
124
|
.option('-w, --workspace <path>', 'Workspace directory')
|
|
125
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
|
+
.option('--host <kind>', 'PRI-743: explicit host attribution (openclaw | codex). openclaw = default attribution without the disclosure warning; codex refuses — the CLI cannot verify Codex lineage.', undefined)
|
|
126
127
|
.option('--wait', 'Wait for diagnosis to complete (sync mode, overrides async flag)')
|
|
127
128
|
.option('--json', 'Output raw JSON')
|
|
128
129
|
.action(async (opts) => {
|
|
@@ -422,7 +422,10 @@ describe('resolveRuntimeAdapterFromConfig (PRI-431)', () => {
|
|
|
422
422
|
});
|
|
423
423
|
|
|
424
424
|
expect(result).toHaveProperty('__type', 'PiAiRuntimeAdapter');
|
|
425
|
-
|
|
425
|
+
// PRI-719 (+review): the resolver threads the optional per-call options
|
|
426
|
+
// (agentName + ignoreAgentEnabled, both undefined when the caller does
|
|
427
|
+
// not select a peer stage).
|
|
428
|
+
expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith('/ws', { agentName: undefined, ignoreAgentEnabled: undefined });
|
|
426
429
|
});
|
|
427
430
|
|
|
428
431
|
it('delegates to openclaw-cli when config resolves to openclaw-cli', () => {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
resolveRuntimeConfigForAgent,
|
|
18
18
|
isRuntimeConfigError,
|
|
19
19
|
resolveAgentRuntimeBinding,
|
|
20
20
|
} from '@principles/core/runtime-v2';
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
RuntimeConfig,
|
|
24
24
|
RuntimeConfigError,
|
|
25
25
|
} from '@principles/core/runtime-v2';
|
|
26
|
+
import type { InternalAgentName } from '@principles/core/runtime-v2';
|
|
26
27
|
import { loadPdConfig } from './pd-config-loader.js';
|
|
27
28
|
import type { PdConfigLoadResult } from './pd-config-loader.js';
|
|
28
29
|
|
|
@@ -64,6 +65,31 @@ function buildProfileLabel(profileId: string, profile: { type: string; provider?
|
|
|
64
65
|
return `pi-ai: ${profile.provider ?? 'unknown'}/${profile.model ?? 'unknown'}`;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* PRI-719: per-call resolution options for resolveRuntimeFromPdConfig.
|
|
70
|
+
*/
|
|
71
|
+
export interface ResolveRuntimeFromPdConfigOptions {
|
|
72
|
+
/** Env var accessor, defaults to process.env. */
|
|
73
|
+
readonly getEnvVar?: (name: string) => string | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Whose `internalAgents.agents[agent]` .runtimeProfile binding resolves
|
|
76
|
+
* (default 'diagnostician' — the pain-signal bridge path). run-once passes
|
|
77
|
+
* the selected runner's agent so each stage executes on ITS declared
|
|
78
|
+
* profile (EP002-R2 F4).
|
|
79
|
+
*/
|
|
80
|
+
readonly agentName?: InternalAgentName;
|
|
81
|
+
/**
|
|
82
|
+
* PRI-719 review: resolve the binding even when the agent is disabled.
|
|
83
|
+
* Peer execution scope (auto-consumer AND explicit run-once) is governed
|
|
84
|
+
* by the internalization_full_chain FLAG, not by
|
|
85
|
+
* internalAgents.agents[kind].enabled — the shipped default config
|
|
86
|
+
* disables philosopher/evaluator/rolloutReviewer yet the full chain runs
|
|
87
|
+
* them. `enabled` keeps gating the diagnostician bridge (callers that
|
|
88
|
+
* omit this option honor it, PRI-638 semantics unchanged).
|
|
89
|
+
*/
|
|
90
|
+
readonly ignoreAgentEnabled?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
/**
|
|
68
94
|
* Resolve runtime configuration exclusively from .pd/config.yaml.
|
|
69
95
|
*
|
|
@@ -72,13 +98,15 @@ function buildProfileLabel(profileId: string, profile: { type: string; provider?
|
|
|
72
98
|
* called by probe/run-once/diagnose/pain-retry.
|
|
73
99
|
*
|
|
74
100
|
* @param workspaceDir - The resolved workspace directory.
|
|
75
|
-
* @param
|
|
101
|
+
* @param options - Per-call resolution options (defaults = diagnostician
|
|
102
|
+
* binding, honor enabled, process.env).
|
|
76
103
|
* @returns Resolved runtime config with legacy warnings.
|
|
77
104
|
*/
|
|
78
105
|
export function resolveRuntimeFromPdConfig(
|
|
79
106
|
workspaceDir: string,
|
|
80
|
-
|
|
107
|
+
options: ResolveRuntimeFromPdConfigOptions = {},
|
|
81
108
|
): ResolvedRuntimeFromPdConfig {
|
|
109
|
+
const { getEnvVar = (name) => process.env[name], agentName = 'diagnostician', ignoreAgentEnabled = false } = options;
|
|
82
110
|
const configLoadResult = loadPdConfig(workspaceDir);
|
|
83
111
|
|
|
84
112
|
// Malformed config → fail loud. Do NOT fall back to defaults for execution.
|
|
@@ -110,15 +138,25 @@ export function resolveRuntimeFromPdConfig(
|
|
|
110
138
|
};
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
const result =
|
|
141
|
+
const result = resolveRuntimeConfigForAgent(configLoadResult.effective, agentName, { getEnvVar, ignoreAgentEnabled });
|
|
114
142
|
|
|
115
143
|
// PRI-402: Extract profile ID and label for probe output alignment with doctor
|
|
116
144
|
let runtimeProfileId: string | null = null;
|
|
117
145
|
let runtimeProfileLabel: string | null = null;
|
|
118
|
-
const bindingResult = resolveAgentRuntimeBinding(configLoadResult.effective,
|
|
146
|
+
const bindingResult = resolveAgentRuntimeBinding(configLoadResult.effective, agentName);
|
|
119
147
|
if (bindingResult.ok) {
|
|
120
148
|
runtimeProfileId = bindingResult.profileId;
|
|
121
149
|
runtimeProfileLabel = buildProfileLabel(bindingResult.profileId, bindingResult.profile);
|
|
150
|
+
} else if (!isRuntimeConfigError(result) && result.runtimeProfileId !== undefined) {
|
|
151
|
+
// PRI-719 review: on the peer path (ignoreAgentEnabled) the runtime may
|
|
152
|
+
// resolve fine for a shipped-disabled agent while the raw binding still
|
|
153
|
+
// reports disabled — derive the label from the resolved profile identity.
|
|
154
|
+
const { runtimeProfileId: resolvedProfileId } = result;
|
|
155
|
+
const profile = configLoadResult.effective.config.runtimeProfiles[resolvedProfileId];
|
|
156
|
+
if (resolvedProfileId !== undefined && profile) {
|
|
157
|
+
runtimeProfileId = resolvedProfileId;
|
|
158
|
+
runtimeProfileLabel = buildProfileLabel(resolvedProfileId, profile);
|
|
159
|
+
}
|
|
122
160
|
}
|
|
123
161
|
|
|
124
162
|
const legacyWarnings = configLoadResult.legacyFilesDetected.length > 0
|
|
@@ -161,7 +199,7 @@ export function resolveRuntimeWithOverrides(
|
|
|
161
199
|
},
|
|
162
200
|
getEnvVar: (name: string) => string | undefined = (name) => process.env[name],
|
|
163
201
|
): ResolvedRuntimeFromPdConfig & { mergedConfig: RuntimeConfig | null } {
|
|
164
|
-
const base = resolveRuntimeFromPdConfig(workspaceDir, getEnvVar);
|
|
202
|
+
const base = resolveRuntimeFromPdConfig(workspaceDir, { getEnvVar });
|
|
165
203
|
|
|
166
204
|
if (isRuntimeConfigError(base.result)) {
|
|
167
205
|
return { ...base, mergedConfig: null };
|
|
@@ -207,7 +207,7 @@ function resolveRuleHostReadinessUnchecked(
|
|
|
207
207
|
getEnvVar: (name: string) => string | undefined,
|
|
208
208
|
): RuleHostReadinessResult {
|
|
209
209
|
// ── Step 1: Load config ──
|
|
210
|
-
const { configLoadResult } = resolveRuntimeFromPdConfig(workspaceDir, getEnvVar);
|
|
210
|
+
const { configLoadResult } = resolveRuntimeFromPdConfig(workspaceDir, { getEnvVar });
|
|
211
211
|
|
|
212
212
|
if (!configLoadResult.ok) {
|
|
213
213
|
const [firstError] = configLoadResult.errors;
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
isRuntimeConfigError,
|
|
33
33
|
validateRuntimeConfig,
|
|
34
34
|
} from '@principles/core/runtime-v2';
|
|
35
|
-
import type { PDRuntimeAdapter, PdL2ArtifactReader, RuntimeConfig, RuntimeConfigResult } from '@principles/core/runtime-v2';
|
|
35
|
+
import type { PDRuntimeAdapter, PdL2ArtifactReader, RuntimeConfig, RuntimeConfigResult, InternalAgentName } from '@principles/core/runtime-v2';
|
|
36
36
|
import { loadLedger } from '@principles/core/principle-tree-ledger';
|
|
37
37
|
import { loadPdConfig, computeFlagsFromLoadResult } from './pd-config-loader.js';
|
|
38
38
|
import { resolveRuntimeFromPdConfig } from './resolve-runtime-from-pd-config.js';
|
|
@@ -80,6 +80,19 @@ export interface ResolveAdapterOptions {
|
|
|
80
80
|
workspaceDir: string;
|
|
81
81
|
/** Runner kind (for L2 dreamer routing). Optional. */
|
|
82
82
|
runnerKind?: string;
|
|
83
|
+
/**
|
|
84
|
+
* PRI-719: internal agent whose runtimeProfile binding resolves
|
|
85
|
+
* (default 'diagnostician'). run-once passes the selected runner's agent
|
|
86
|
+
* so each stage executes on its own declared profile.
|
|
87
|
+
*/
|
|
88
|
+
agentName?: InternalAgentName;
|
|
89
|
+
/**
|
|
90
|
+
* PRI-719 review: resolve the binding even when the agent is disabled —
|
|
91
|
+
* peer execution scope is the internalization_full_chain flag (auto-
|
|
92
|
+
* consumer AND explicit run-once), not agents[kind].enabled. Omitted by
|
|
93
|
+
* non-peer callers (probe/diagnose) which keep honoring `enabled`.
|
|
94
|
+
*/
|
|
95
|
+
ignoreAgentEnabled?: boolean;
|
|
83
96
|
/** CLI timeout override. Takes precedence over config timeoutMs. */
|
|
84
97
|
timeoutMs?: number;
|
|
85
98
|
/**
|
|
@@ -163,7 +176,10 @@ export function resolveRuntimeAdapterFromConfig(opts: ResolveAdapterOptions): PD
|
|
|
163
176
|
}
|
|
164
177
|
|
|
165
178
|
// ── Resolve config from .pd/config.yaml (for pi-ai, openclaw-cli, config) ──
|
|
166
|
-
const resolved = resolveRuntimeFromPdConfig(opts.workspaceDir
|
|
179
|
+
const resolved = resolveRuntimeFromPdConfig(opts.workspaceDir, {
|
|
180
|
+
agentName: opts.agentName,
|
|
181
|
+
ignoreAgentEnabled: opts.ignoreAgentEnabled,
|
|
182
|
+
});
|
|
167
183
|
const configResult: RuntimeConfigResult = resolved.result;
|
|
168
184
|
|
|
169
185
|
// PRI-431 Step 1d: invoke onConfigResolved callback (for telemetry, legacyWarnings, etc.)
|
|
@@ -105,6 +105,39 @@ describe('listPains (PRI-640 host filter)', () => {
|
|
|
105
105
|
expect(result.pains[0]?.host).toBe('unknown');
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it('PRI-743: byHost reports the full-table distribution independent of --host/--limit', async () => {
|
|
109
|
+
const { db } = makeWorkspace();
|
|
110
|
+
seedPain(db, { id: 1, source: 'user_correction', canonical: 'pain_oc_1', host: 'openclaw', created: '2026-09-01T10:00:00.000Z' });
|
|
111
|
+
seedPain(db, { id: 2, source: 'user_correction', canonical: 'pain_oc_2', host: 'openclaw', created: '2026-09-01T10:05:00.000Z' });
|
|
112
|
+
seedPain(db, { id: 3, source: 'tool_failure', canonical: 'pain_cx_1', host: 'codex', created: '2026-09-01T11:00:00.000Z' });
|
|
113
|
+
seedPain(db, { id: 4, source: 'manual', canonical: 'pain_null_1', host: null, created: '2026-09-01T12:00:00.000Z' });
|
|
114
|
+
seedPain(db, { id: 5, source: 'manual', canonical: 'pain_null_2', host: null, created: '2026-09-01T12:05:00.000Z' });
|
|
115
|
+
const dbPath = dbPathOf(db);
|
|
116
|
+
db.close();
|
|
117
|
+
|
|
118
|
+
// --limit narrows the row list but must NOT narrow the distribution.
|
|
119
|
+
const limited = await listPains(dbPath, { limit: 2 });
|
|
120
|
+
expect(limited.count).toBe(2);
|
|
121
|
+
expect(limited.byHost).toEqual({ openclaw: 2, codex: 1, unknown: 2 });
|
|
122
|
+
|
|
123
|
+
// --host narrows the row list but must NOT narrow the distribution.
|
|
124
|
+
const filtered = await listPains(dbPath, { limit: 10, host: 'codex' });
|
|
125
|
+
expect(filtered.count).toBe(1);
|
|
126
|
+
expect(filtered.byHost).toEqual({ openclaw: 2, codex: 1, unknown: 2 });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('PRI-743: byHost is null (never guessed) on a pre-PRI-640 database', async () => {
|
|
130
|
+
const { db } = makePre640Workspace();
|
|
131
|
+
db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at)
|
|
132
|
+
VALUES ('s1', 'tool_failure', 70, 'r', 'moderate', 'system_infer', NULL, NULL, 'pain_legacy', NULL, '2026-08-01T00:00:00.000Z')`).run();
|
|
133
|
+
const dbPath = dbPathOf(db);
|
|
134
|
+
db.close();
|
|
135
|
+
|
|
136
|
+
const result = await listPains(dbPath, { limit: 10 });
|
|
137
|
+
expect(result.byHost).toBeNull();
|
|
138
|
+
expect(result.warnings).toContain('host_kind_column_missing');
|
|
139
|
+
});
|
|
140
|
+
|
|
108
141
|
it('degrades observably on a pre-PRI-640 database without the host_kind column (rc-9)', async () => {
|
|
109
142
|
const { db } = makePre640Workspace();
|
|
110
143
|
db.prepare(`INSERT INTO pain_events (session_id, source, score, reason, severity, origin, confidence, text, canonical_pain_id, runtime_task_id, created_at)
|
|
@@ -152,11 +185,12 @@ describe('handlePainList (CLI contract)', () => {
|
|
|
152
185
|
await handlePainList({ json: true });
|
|
153
186
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
154
187
|
const raw = logSpy.mock.calls.map((args) => String(args[0])).join('\n');
|
|
155
|
-
const parsed = JSON.parse(raw) as { count: number; pains: { host: string; painId: string }[]; hostFilter: unknown; warnings: string[] };
|
|
188
|
+
const parsed = JSON.parse(raw) as { count: number; pains: { host: string; painId: string }[]; hostFilter: unknown; warnings: string[]; byHost: { openclaw: number; codex: number; unknown: number } | null };
|
|
156
189
|
expect(parsed.count).toBe(2);
|
|
157
190
|
expect(parsed.pains.map((p) => `${p.painId}:${p.host}`).sort()).toEqual(['pain_cli_cx:codex', 'pain_cli_oc:openclaw']);
|
|
158
191
|
expect(parsed.hostFilter).toBeNull();
|
|
159
192
|
expect(parsed.warnings).toEqual([]);
|
|
193
|
+
expect(parsed.byHost).toEqual({ openclaw: 1, codex: 1, unknown: 0 });
|
|
160
194
|
});
|
|
161
195
|
|
|
162
196
|
it('--host filter is reflected in the JSON result', async () => {
|
|
@@ -86,6 +86,33 @@ describe('pd pain record --session (real Commander + real trajectory.db)', () =>
|
|
|
86
86
|
expect(result.stdout).toContain('--session');
|
|
87
87
|
}, 15_000);
|
|
88
88
|
|
|
89
|
+
it('PRI-743: --help registers --host with the openclaw|codex contract', async () => {
|
|
90
|
+
const result = await runBuiltCli(['pain', 'record', '--help']);
|
|
91
|
+
expect(result.status).toBe(0);
|
|
92
|
+
expect(result.stdout).toContain('--host <kind>');
|
|
93
|
+
// Commander wraps help text; normalize whitespace before asserting content.
|
|
94
|
+
const normalized = result.stdout.replace(/\s+/g, ' ');
|
|
95
|
+
expect(normalized).toContain('openclaw | codex');
|
|
96
|
+
expect(normalized).toContain('codex refuses');
|
|
97
|
+
}, 15_000);
|
|
98
|
+
|
|
99
|
+
it('PRI-743: an invalid --host value exits non-zero with a single structured JSON object', async () => {
|
|
100
|
+
const result = await runBuiltCli([
|
|
101
|
+
'pain', 'record',
|
|
102
|
+
'--reason', 'parser test pain',
|
|
103
|
+
'--host', 'claude',
|
|
104
|
+
'--workspace', tmpDir,
|
|
105
|
+
'--json',
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
expect(result.status).not.toBe(0);
|
|
109
|
+
const trimmed = result.stdout.trim();
|
|
110
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
111
|
+
expect(parsed.status).toBe('failed');
|
|
112
|
+
expect(parsed.reason).toBe('invalid_host_kind');
|
|
113
|
+
expect(typeof parsed.nextAction).toBe('string');
|
|
114
|
+
}, 15_000);
|
|
115
|
+
|
|
89
116
|
it('fails with a single JSON object and reason session_not_found for a nonexistent session (SPEC 12.1.4)', async () => {
|
|
90
117
|
const result = await runBuiltCli([
|
|
91
118
|
'pain', 'record',
|
|
@@ -399,6 +399,83 @@ describe('pd pain record', () => {
|
|
|
399
399
|
exitSpy.mockRestore();
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
+
// ── PRI-743: explicit --host attribution ──────────────────────────────────
|
|
403
|
+
|
|
404
|
+
it('PRI-743: --host openclaw records without the default-assumption disclosure', async () => {
|
|
405
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
406
|
+
const exitSpy = mockProcessExit();
|
|
407
|
+
|
|
408
|
+
await handlePainRecord({ reason: 'explicit openclaw', session: 'sess-oc', host: 'openclaw', json: true });
|
|
409
|
+
|
|
410
|
+
expect(lastRecordPainInput).toBeTruthy();
|
|
411
|
+
expect(lastRecordPainInput!.sessionId).toBe('sess-oc');
|
|
412
|
+
expect(lastRecordPainInput!.hostKind).toBe('openclaw');
|
|
413
|
+
expect(lastRecordPainInput!.provenance).toBe('host_context_bound');
|
|
414
|
+
|
|
415
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { hostAttribution?: string; warnings?: string[] };
|
|
416
|
+
expect(jsonOutput.hostAttribution).toBe('openclaw');
|
|
417
|
+
expect((jsonOutput.warnings ?? []).some((w) => w.includes("defaulted to 'openclaw'"))).toBe(false);
|
|
418
|
+
|
|
419
|
+
logSpy.mockRestore();
|
|
420
|
+
exitSpy.mockRestore();
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('PRI-743: --host codex refuses loudly — the CLI cannot verify Codex lineage (rc-6)', async () => {
|
|
424
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
425
|
+
const exitSpy = mockProcessExit();
|
|
426
|
+
|
|
427
|
+
await handlePainRecord({ reason: 'codex mistake', session: 'sess-codex', host: 'codex', json: true });
|
|
428
|
+
|
|
429
|
+
// cli-1: exactly one JSON object on stdout
|
|
430
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
431
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { status: string; reason: string; nextAction: string };
|
|
432
|
+
expect(jsonOutput).toMatchObject({ status: 'failed', reason: 'codex_lineage_unverifiable_by_cli' });
|
|
433
|
+
expect(jsonOutput.nextAction).toContain('Codex ingestion');
|
|
434
|
+
// cli-2/cli-5: refused before any evidence acquisition or service mutation
|
|
435
|
+
expect(lastRecordPainInput).toBeNull();
|
|
436
|
+
expect(acquireTrajectoryEvidenceFromDb).not.toHaveBeenCalled();
|
|
437
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
438
|
+
|
|
439
|
+
logSpy.mockRestore();
|
|
440
|
+
exitSpy.mockRestore();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('PRI-743: omitting --host keeps the openclaw default but discloses the assumption (rc-9)', async () => {
|
|
444
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
445
|
+
const exitSpy = mockProcessExit();
|
|
446
|
+
|
|
447
|
+
await handlePainRecord({ reason: 'legacy path', session: 'sess-123', json: true });
|
|
448
|
+
|
|
449
|
+
expect(lastRecordPainInput).toBeTruthy();
|
|
450
|
+
expect(lastRecordPainInput!.hostKind).toBe('openclaw');
|
|
451
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { hostAttribution?: string; warnings?: string[] };
|
|
452
|
+
expect(jsonOutput.hostAttribution).toBe('openclaw');
|
|
453
|
+
expect((jsonOutput.warnings ?? []).some((w) => w.includes("defaulted to 'openclaw'") && w.includes('--host codex'))).toBe(true);
|
|
454
|
+
|
|
455
|
+
logSpy.mockRestore();
|
|
456
|
+
exitSpy.mockRestore();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('PRI-743: an invalid --host value fails loudly before any mutation (cli-5/cli-6)', async () => {
|
|
460
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
461
|
+
const exitSpy = mockProcessExit();
|
|
462
|
+
|
|
463
|
+
await handlePainRecord({ reason: 'typo host', session: 'sess-123', host: 'claude', json: true });
|
|
464
|
+
|
|
465
|
+
// cli-1: exactly one JSON object on stdout
|
|
466
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
467
|
+
const jsonOutput = JSON.parse(logSpy.mock.calls[0][0]) as { status: string; reason: string; nextAction: string };
|
|
468
|
+
expect(jsonOutput).toMatchObject({ status: 'failed', reason: 'invalid_host_kind' });
|
|
469
|
+
expect(jsonOutput.nextAction).toContain('--host codex');
|
|
470
|
+
// cli-2/cli-5: execution stopped before any evidence acquisition or service mutation
|
|
471
|
+
expect(lastRecordPainInput).toBeNull();
|
|
472
|
+
expect(acquireTrajectoryEvidenceFromDb).not.toHaveBeenCalled();
|
|
473
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
474
|
+
|
|
475
|
+
logSpy.mockRestore();
|
|
476
|
+
exitSpy.mockRestore();
|
|
477
|
+
});
|
|
478
|
+
|
|
402
479
|
// 用例 C2 (PRI-642 rewrite): without session, no sentinel session, no
|
|
403
480
|
// placeholder evidence — an honest unbound Owner report (SPEC §7.4).
|
|
404
481
|
it('C2: submits an honest unbound report when no --session provided', async () => {
|