@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.
Files changed (42) hide show
  1. package/dist/commands/pain-list.d.ts +14 -0
  2. package/dist/commands/pain-list.d.ts.map +1 -1
  3. package/dist/commands/pain-list.js +28 -1
  4. package/dist/commands/pain-list.js.map +1 -1
  5. package/dist/commands/pain-record.d.ts +2 -0
  6. package/dist/commands/pain-record.d.ts.map +1 -1
  7. package/dist/commands/pain-record.js +71 -4
  8. package/dist/commands/pain-record.js.map +1 -1
  9. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  10. package/dist/commands/runtime-internalization-run-once.js +23 -7
  11. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/services/__tests__/runtime-adapter-resolver.test.js +4 -1
  15. package/dist/services/__tests__/runtime-adapter-resolver.test.js.map +1 -1
  16. package/dist/services/resolve-runtime-from-pd-config.d.ts +28 -2
  17. package/dist/services/resolve-runtime-from-pd-config.d.ts.map +1 -1
  18. package/dist/services/resolve-runtime-from-pd-config.js +19 -6
  19. package/dist/services/resolve-runtime-from-pd-config.js.map +1 -1
  20. package/dist/services/rulehost-readiness.js +1 -1
  21. package/dist/services/rulehost-readiness.js.map +1 -1
  22. package/dist/services/runtime-adapter-resolver.d.ts +14 -1
  23. package/dist/services/runtime-adapter-resolver.d.ts.map +1 -1
  24. package/dist/services/runtime-adapter-resolver.js +4 -1
  25. package/dist/services/runtime-adapter-resolver.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/pain-list.ts +37 -1
  28. package/src/commands/pain-record.ts +73 -4
  29. package/src/commands/runtime-internalization-run-once.ts +23 -7
  30. package/src/index.ts +1 -0
  31. package/src/services/__tests__/runtime-adapter-resolver.test.ts +4 -1
  32. package/src/services/resolve-runtime-from-pd-config.ts +44 -6
  33. package/src/services/rulehost-readiness.ts +1 -1
  34. package/src/services/runtime-adapter-resolver.ts +18 -2
  35. package/tests/commands/pain-list.test.ts +35 -1
  36. package/tests/commands/pain-record-session-parser.test.ts +27 -0
  37. package/tests/commands/pain-record.test.ts +77 -0
  38. package/tests/commands/pri-393-runtime-config-unification.test.ts +13 -11
  39. package/tests/commands/runtime-internalization-run-once-language.test.ts +12 -0
  40. package/tests/commands/runtime-internalization-run-once-real-config.test.ts +237 -0
  41. package/tests/commands/runtime-internalization-run-once.test.ts +21 -5
  42. 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: 'openclaw', sessionId: opts.session },
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 { resolveRuntimeConfigFromPdConfig, isRuntimeConfigError } from '@principles/core/runtime-v2';
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. The shared
505
- // resolver reads the diagnostician binding (the binding ALL peer stages
506
- // share today) on any resolution error, fall back to the 300s default
507
- // rather than refusing the run: the flag-off/legacy behavior was 300s.
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 = resolveRuntimeConfigFromPdConfig(effectiveConfig, (name) => process.env[name]);
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
- expect(mockResolveRuntimeFromPdConfig).toHaveBeenCalledWith('/ws');
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
- resolveRuntimeConfigFromPdConfig,
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 getEnvVar - Env var accessor, defaults to process.env.
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
- getEnvVar: (name: string) => string | undefined = (name) => process.env[name],
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 = resolveRuntimeConfigFromPdConfig(configLoadResult.effective, getEnvVar);
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, 'diagnostician');
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 () => {