@principles/pd-cli 1.145.2 → 1.146.1

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.
@@ -64,6 +64,57 @@ function getConsoleDir(): { dir: string; mode: InstallLayoutMode } | null {
64
64
  return { dir, mode: resolved.mode };
65
65
  }
66
66
 
67
+ /**
68
+ * Verify the console server's runtime dependency slots are resolvable.
69
+ *
70
+ * The console's dist statically imports `@principles/core/runtime-v2` and
71
+ * `@principles/host-runtime`, and reads plugin governance exports from
72
+ * `principles-disciple`. Each slot must be a real package (package.json present
73
+ * AND dist/index.js present) — a dangling junction or an empty shell (package.json
74
+ * without dist) makes the server crash with ERR_MODULE_NOT_FOUND at startup.
75
+ *
76
+ * Returns a human-readable description of the first broken slot, or undefined
77
+ * when everything is resolvable.
78
+ */
79
+ export function checkConsoleRuntimeDependencies(consoleDir: string): string | undefined {
80
+ // These are the exact entry files the console server resolves at startup,
81
+ // derived from each package's real `exports` map (not assumed dist/index.js):
82
+ // @principles/core/principle-tree-ledger → dist/principle-tree-ledger.js
83
+ // @principles/core/runtime-v2 → dist/runtime-v2/index.js
84
+ // @principles/host-runtime → dist/index.js
85
+ // @principles/install-layout → dist/index.js
86
+ // principles-disciple/governance-audit → dist/governance-audit.js
87
+ // Checking the real resolved entries (not just dist/index.js) catches broken
88
+ // shells where package.json exists but the actual import target is missing.
89
+ // One package can have multiple required entries (@principles/core).
90
+ const slots: { pkg: string; entries: string[] }[] = [
91
+ {
92
+ pkg: '@principles/core',
93
+ entries: [
94
+ path.join('dist', 'principle-tree-ledger.js'),
95
+ path.join('dist', 'runtime-v2', 'index.js'),
96
+ ],
97
+ },
98
+ { pkg: '@principles/host-runtime', entries: [path.join('dist', 'index.js')] },
99
+ { pkg: '@principles/install-layout', entries: [path.join('dist', 'index.js')] },
100
+ { pkg: 'principles-disciple', entries: [path.join('dist', 'governance-audit.js')] },
101
+ ];
102
+ for (const slot of slots) {
103
+ const base = path.join(consoleDir, 'node_modules', slot.pkg);
104
+ const pkgJson = path.join(base, 'package.json');
105
+ if (!fs.existsSync(pkgJson)) {
106
+ return `console/node_modules/${slot.pkg}/package.json is missing`;
107
+ }
108
+ for (const entry of slot.entries) {
109
+ const entryFile = path.join(base, entry);
110
+ if (!fs.existsSync(entryFile)) {
111
+ return `console/node_modules/${slot.pkg}/${entry.replaceAll('\\', '/')} is missing (incomplete package)`;
112
+ }
113
+ }
114
+ }
115
+ return undefined;
116
+ }
117
+
67
118
  export async function handleConsole(opts: ConsoleOptions = {}): Promise<void> {
68
119
  const workspaceDir = opts.workspace
69
120
  ? path.resolve(opts.workspace)
@@ -108,6 +159,27 @@ export async function handleConsole(opts: ConsoleOptions = {}): Promise<void> {
108
159
  return;
109
160
  }
110
161
 
162
+ // Runtime dependency integrity check: the console server statically imports
163
+ // @principles/core/runtime-v2 and @principles/host-runtime, and the console
164
+ // reads plugin governance exports from principles-disciple. When the OpenClaw
165
+ // upgrade path refreshes the plugin bundle without re-running the PD installer
166
+ // (2026-09: console_cli_exited_before_result), these node_modules slots can be
167
+ // left as an empty shell (package.json without dist) or a dangling junction.
168
+ // The server then crashes with ERR_MODULE_NOT_FOUND at startup, which the
169
+ // Companion reports as the opaque console_cli_exited_before_result. Fail loud
170
+ // here with the actionable repair command instead.
171
+ const integrityError = checkConsoleRuntimeDependencies(consoleLocation.dir);
172
+ if (integrityError) {
173
+ const msg = `Console runtime dependency broken: ${integrityError}. Re-run installer to repair.`;
174
+ if (opts.json) {
175
+ console.log(JSON.stringify({ success: false, reason: msg, nextAction: 'npx create-principles-disciple' }));
176
+ } else {
177
+ console.error(msg);
178
+ }
179
+ process.exit(1);
180
+ return;
181
+ }
182
+
111
183
  const port = opts.port || '3100';
112
184
  const host = '127.0.0.1';
113
185
  const args = [serverEntry, '--workspace', workspaceDir, '--port', port, '--host', host];
@@ -485,6 +557,39 @@ export async function handleConsoleOpen(opts: ConsoleOpenOptions = {}): Promise<
485
557
  }
486
558
 
487
559
  // 5) Fresh spawn path
560
+ // Runtime dependency integrity check — only here, NOT before the reuse
561
+ // probe above. A healthy console already running (reused) has its modules
562
+ // loaded in memory; broken disk slots (dangling junction / empty shell left
563
+ // by an interrupted update) must not prevent reusing it. Only when we are
564
+ // about to execute the local console bytes do the local bytes need to be
565
+ // resolvable. The server statically imports @principles/core/runtime-v2 and
566
+ // @principles/host-runtime, and reads plugin governance exports from
567
+ // principles-disciple; without this check a broken install crashes with
568
+ // ERR_MODULE_NOT_FOUND which the Companion reports as the opaque
569
+ // console_cli_exited_before_result.
570
+ const integrityError = checkConsoleRuntimeDependencies(consoleLocation.dir);
571
+ if (integrityError) {
572
+ const result: ConsoleLaunchResult = {
573
+ status: 'failed',
574
+ url: '',
575
+ port: plan.port,
576
+ host: plan.host,
577
+ workspaceDir,
578
+ reused: false,
579
+ browserOpened: false,
580
+ reason: 'console_runtime_dependency_broken',
581
+ nextAction: `Re-run installer: npx create-principles-disciple (${integrityError})`,
582
+ };
583
+ if (opts.json) {
584
+ console.log(JSON.stringify(result, null, 2));
585
+ } else {
586
+ console.error(`error: ${result.reason}`);
587
+ console.error(`next: ${result.nextAction}`);
588
+ }
589
+ process.exit(1);
590
+ return;
591
+ }
592
+
488
593
  const args = [serverEntry, '--workspace', workspaceDir, '--port', String(plan.port), '--host', plan.host];
489
594
  if (opts.noAuth) args.push('--no-auth');
490
595
  if (opts.token) args.push('--token', opts.token);
@@ -2,6 +2,13 @@
2
2
  * pd pain record command — Runtime v2 pain signal entry point.
3
3
  *
4
4
  * Uses PainToPrincipleService as the single write-side orchestration API.
5
+ * PRI-642 (review blocker 1): the ingress semantic interpretation — origin,
6
+ * bound/unbound correlation, provenance derivation, sentinel handling,
7
+ * submit/degrade/refuse semantics and painIngress.v1 construction — comes
8
+ * from the SHARED evaluator `evaluatePainIngress` in @principles/core
9
+ * (the same authority the OpenClaw funnel uses). This adapter only does
10
+ * host-specific ACQUISITION: read --session, validate it against this
11
+ * workspace's trajectory.db, and collect raw evidence entries.
5
12
  *
6
13
  * Usage:
7
14
  * pd pain record --reason <text> [--score N] [--source manual] [--workspace <path>] [--session <id>] [--json]
@@ -13,10 +20,12 @@ import {
13
20
  isRuntimeConfigError,
14
21
  isFeatureEnabled,
15
22
  isBuiltinPiAiProvider,
23
+ evaluatePainIngress,
16
24
  } from '@principles/core/runtime-v2';
25
+ import type { PainIngressDecision, IngressEvidenceEntry, PainEvidenceEntry } from '@principles/core/runtime-v2';
17
26
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
18
27
  import { loadPdConfig, computeFlagsFromLoadResult } from '../services/pd-config-loader.js';
19
- import { buildTrajectoryEvidenceFromDb } from './build-trajectory-evidence.js';
28
+ import { acquireTrajectoryEvidenceFromDb } from './build-trajectory-evidence.js';
20
29
 
21
30
  interface RecordOptions {
22
31
  reason?: string;
@@ -28,6 +37,125 @@ interface RecordOptions {
28
37
  wait?: boolean;
29
38
  }
30
39
 
40
+ function emitSessionBindingFailure(
41
+ opts: RecordOptions,
42
+ failure: { reason: string; message: string; nextAction: string },
43
+ ): void {
44
+ if (opts.json) {
45
+ console.log(JSON.stringify({
46
+ status: 'failed',
47
+ reason: failure.reason,
48
+ message: failure.message,
49
+ nextAction: failure.nextAction,
50
+ }, null, 2));
51
+ } else {
52
+ console.error(`Error: ${failure.message}`);
53
+ console.error(`Reason: ${failure.reason}`);
54
+ console.error(`Next action: ${failure.nextAction}`);
55
+ }
56
+ process.exit(1);
57
+ }
58
+
59
+ function toIngressEntry(entry: PainEvidenceEntry): IngressEvidenceEntry {
60
+ // Trajectory evidence references observed behavior (owner messages,
61
+ // assistant turns, tool-call failures) — behavior traces.
62
+ return { kind: 'behavior_trace', sourceRef: entry.sourceRef, note: entry.note };
63
+ }
64
+
65
+ /**
66
+ * PRI-642 (SPEC §7.3/§7.4, review blocker 1): build the report from this
67
+ * adapter's host-specific facts and let the SHARED evaluator decide.
68
+ *
69
+ * Per-channel policy (SPEC §7.3 vs OpenClaw):
70
+ *
71
+ * - explicit `--session` + acquisition available (real session + real entries):
72
+ * → cli_explicit_session + bound → submit with real evidence.
73
+ * - explicit `--session` + acquisition unavailable (session_not_found /
74
+ * empty_trajectory / trajectory_unavailable / evidence_read_failed):
75
+ * → cli_explicit_session + unbound → shared evaluator REFUSES
76
+ * (row 5) before any LLM/task/candidate mutation.
77
+ * The CLI does NOT share the OpenClaw funnel's "degrade" semantics
78
+ * because the CLI does not own the session identity the way the host
79
+ * command context does (Evidence Over Assumption — an unverified
80
+ * session claimed as bound would be a PRI-642 recurrence).
81
+ * - no `--session` → unbound Owner report (matrix row 6) → submit with
82
+ * disclosure; no sentinel session, no placeholder evidence, no trajectory
83
+ * projection (the CLI skips observability when the ingress yields no
84
+ * bound session — SPEC §7.4).
85
+ */
86
+ function resolveIngressDecision(
87
+ input: { opts: RecordOptions; stateDir: string; workspaceDir: string; painId: string },
88
+ ): { decision: PainIngressDecision; acquisitionDetail: string | null; acquisitionReason: string | null } {
89
+ const { opts, stateDir, workspaceDir, painId } = input;
90
+ const score = opts.score ?? 80;
91
+ const base = {
92
+ identity: { kind: 'manual_pain_id' as const, painId },
93
+ painType: 'user_frustration' as const,
94
+ source: opts.source ?? 'manual',
95
+ reason: opts.reason ?? '',
96
+ score,
97
+ };
98
+
99
+ if (!opts.session) {
100
+ // No --session: external unbound Owner report (matrix row 6). Allowed
101
+ // by SPEC §7.4 with a disclosure warning; never claims host binding.
102
+ const decision = evaluatePainIngress({
103
+ ...base,
104
+ origin: { kind: 'owner_manual', channel: 'external_cli_unbound' },
105
+ correlation: { status: 'unbound', reason: 'external_cli' },
106
+ evidence: { status: 'unavailable', reason: 'not_applicable_unbound' },
107
+ });
108
+ return { decision, acquisitionDetail: null, acquisitionReason: null };
109
+ }
110
+
111
+ // --session: SPEC §7.3 demands a fail-loud refusal whenever the CLI
112
+ // cannot actually verify the claimed binding. We do not share the
113
+ // OpenClaw funnel's "degrade" semantics here because the CLI does
114
+ // not own the session identity the way the host command context does
115
+ // (Evidence Over Assumption): an unverified session that we still
116
+ // claimed as bound would be a PRI-642 recurrence in a different
117
+ // shape. Per-channel policy differs; semantic authority is shared.
118
+ const acquisition = acquireTrajectoryEvidenceFromDb(stateDir, opts.session, workspaceDir);
119
+ if (acquisition.status === 'unavailable') {
120
+ // session_not_found / empty_trajectory / trajectory_unavailable /
121
+ // evidence_read_failed — refuse (row 5) before any LLM/task/candidate
122
+ // mutation. The CLI surfaces the reasonCode with a SPEC §7.3 next
123
+ // action; the evaluator produces the refuse decision.
124
+ const {reasonCode} = acquisition;
125
+ const decision = evaluatePainIngress({
126
+ ...base,
127
+ origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
128
+ correlation: { status: 'unbound', reason: 'external_cli' },
129
+ evidence: { status: 'unavailable', reason: reasonCode === 'session_not_found' ? 'not_applicable_unbound' : reasonCode },
130
+ });
131
+ if (decision.action === 'refuse') {
132
+ return { decision, acquisitionDetail: acquisition.detail, acquisitionReason: reasonCode };
133
+ }
134
+ }
135
+
136
+ // Available evidence: a real session with real entries — submit, bound.
137
+ if (acquisition.status === 'available') {
138
+ const decision = evaluatePainIngress({
139
+ ...base,
140
+ origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
141
+ correlation: { status: 'bound', hostKind: 'openclaw', sessionId: opts.session },
142
+ evidence: { status: 'available', entries: acquisition.entries.map(toIngressEntry) as [IngressEvidenceEntry, ...IngressEvidenceEntry[]] },
143
+ });
144
+ return { decision, acquisitionDetail: null, acquisitionReason: null };
145
+ }
146
+
147
+ // Exhaustive — acquireTrajectoryEvidenceFromDb returns {available, unavailable}
148
+ // only. The empty-trajectory branch was collapsed to refuse above; a
149
+ // future-added reason would land here and must also refuse.
150
+ const decision = evaluatePainIngress({
151
+ ...base,
152
+ origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
153
+ correlation: { status: 'unbound', reason: 'external_cli' },
154
+ evidence: { status: 'unavailable', reason: 'not_applicable_unbound' },
155
+ });
156
+ return { decision, acquisitionDetail: null, acquisitionReason: null };
157
+ }
158
+
31
159
  export async function handlePainRecord(opts: RecordOptions): Promise<void> {
32
160
  if (!opts.reason) {
33
161
  console.error('Error: --reason <text> is required');
@@ -50,24 +178,57 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
50
178
  }, null, 2));
51
179
  } else {
52
180
  console.error(`Error: --reason must be at most ${MAX_REASON_LENGTH} characters (got ${opts.reason.length})`);
53
- console.error('Next action: shorten the reason text or split into multiple pain records.');
181
+ console.error('Next action: shorten the reason text or split it into multiple pain records.');
54
182
  }
55
183
  process.exit(1);
56
184
  return;
57
185
  }
58
186
 
59
187
  if (opts.score !== undefined && (isNaN(opts.score) || opts.score < 0 || opts.score > 100)) {
60
- console.error('Error: --score must be a number between 0 and 100');
188
+ if (opts.json) {
189
+ console.log(JSON.stringify({
190
+ status: 'failed',
191
+ reason: 'score_invalid',
192
+ message: '--score must be a number between 0 and 100',
193
+ nextAction: 'Provide --score as an integer from 0 to 100, or omit it for the default (80).',
194
+ }, null, 2));
195
+ } else {
196
+ console.error('Error: --score must be a number between 0 and 100');
197
+ console.error('Next action: provide --score as an integer from 0 to 100, or omit it for the default (80).');
198
+ }
61
199
  process.exit(1);
200
+ return;
62
201
  }
63
202
 
64
203
  const workspaceDir = resolveWorkspaceDir(opts.workspace);
65
204
  const stateDir = `${workspaceDir}/.state`;
66
205
  const painId = `manual_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
67
206
 
68
- // PRI-341: Build evidence from trajectory DB if session provided
69
- const effectiveSessionId = opts.session ?? 'cli';
70
- const evidence = buildTrajectoryEvidenceFromDb(stateDir, opts.session, workspaceDir);
207
+ // PRI-642: run the SHARED ingress evaluator before any service/LLM/task
208
+ // mutation; the decision (submit/degrade/refuse) is not made here.
209
+ const { decision, acquisitionDetail, acquisitionReason } = resolveIngressDecision({ opts, stateDir, workspaceDir, painId });
210
+
211
+ if (decision.action === 'refuse') {
212
+ // SPEC §7.3: the CLI must surface the specific acquisition reason
213
+ // (session_not_found / empty_trajectory / trajectory_unavailable /
214
+ // evidence_read_failed) — the shared evaluator produces the refuse
215
+ // decision; we map the acquisition detail to the Operator reason.
216
+ const reason = opts.session !== undefined && acquisitionReason !== null
217
+ ? acquisitionReason
218
+ : decision.reasonCode;
219
+ const detailSuffix = acquisitionDetail !== null ? ` (${acquisitionDetail})` : '';
220
+ emitSessionBindingFailure(opts, {
221
+ reason,
222
+ message: `${decision.warning}${detailSuffix}`,
223
+ nextAction: decision.nextAction,
224
+ });
225
+ return;
226
+ }
227
+
228
+ const binding = decision.legacy;
229
+ // SPEC §7.4: without a bound session the trajectory projection is skipped
230
+ // (disclosed below) instead of fabricating the sentinel session 'cli'.
231
+ const recordObservability = binding.sessionId !== undefined;
71
232
 
72
233
  const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir });
73
234
  // PRI-306: Load .pd/config.yaml for config-driven runtime binding
@@ -104,11 +265,13 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
104
265
  source: opts.source ?? 'manual',
105
266
  reason: opts.reason,
106
267
  score: opts.score ?? 80,
107
- sessionId: effectiveSessionId,
268
+ sessionId: binding.sessionId,
108
269
  agentId: 'pd-cli',
109
- provenance: 'owner_reported_no_host_trace',
110
- evidence,
111
- recordObservability: true,
270
+ provenance: binding.provenance,
271
+ hostKind: binding.hostKind,
272
+ evidence: binding.evidence,
273
+ recordObservability,
274
+ painIngress: binding.painIngress,
112
275
  });
113
276
 
114
277
  // Show diagnostic info for config failures
@@ -181,6 +344,25 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
181
344
  }
182
345
  }
183
346
 
347
+ // PRI-642 (SPEC §12.1.6): generated-but-unadmitted candidates must never
348
+ // read as completed internalization — surface the admission disposition.
349
+ const admissionDecisions = result.admissionResults?.map(r => r.admission.decision) ?? [];
350
+ const admittedCount = admissionDecisions.filter(d => d === 'admitted').length;
351
+ const gatedWarning = result.status === 'succeeded'
352
+ && result.candidateIds.length > 0
353
+ && admittedCount === 0
354
+ ? `admitted: 0 of ${result.candidateIds.length} candidates (all gated — needs_evidence/deferred); `
355
+ + 'nothing was internalized. See admissionResults for per-candidate reasons; add --session evidence or Owner context to raise confidence above the admission threshold.'
356
+ : null;
357
+ // PRI-642: the ingress decision's own disclosures (degrade warning /
358
+ // unbound context_unbound warning) are surfaced to the operator.
359
+ const decisionWarnings = decision.action === 'degrade'
360
+ ? [`${decision.warning} Next action: ${decision.nextAction}`]
361
+ : decision.action === 'submit'
362
+ ? decision.warnings
363
+ : [];
364
+ const cliWarnings = [...decisionWarnings, gatedWarning].filter((w): w is string => w !== null && w !== undefined);
365
+
184
366
  if (opts.json) {
185
367
  const out: Record<string, unknown> = { ...result };
186
368
  // Ensure nextAction is present for actionable states
@@ -191,13 +373,22 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
191
373
  if (!out.reason) {
192
374
  out.reason = out.message;
193
375
  }
194
- // PRI-570: async submission has NO automatic consumer — the
195
- // internalization auto-consumer's runner kinds (dreamer..rollout_reviewer)
196
- // do not include 'diagnostician', so a submitted task stays pending until
197
- // the owner runs the diagnose command. Never let that stay implicit (rc-9).
198
- out.warning = 'Async mode: no background consumer picks up diagnostician tasks. '
376
+ }
377
+ // PRI-570: async submission has NO automatic consumer the
378
+ // internalization auto-consumer's runner kinds (dreamer..rollout_reviewer)
379
+ // do not include 'diagnostician', so a submitted task stays pending until
380
+ // the owner runs the diagnose command. Never let that stay implicit (rc-9).
381
+ // PRI-642: unbound/gated disclosures join the same warnings channel.
382
+ const submittedWarning = out.status === 'submitted'
383
+ ? 'Async mode: no background consumer picks up diagnostician tasks. '
199
384
  + `Run ${out.nextAction} or the pain signal will remain pending indefinitely. `
200
- + 'Use --wait to diagnose synchronously instead.';
385
+ + 'Use --wait to diagnose synchronously instead.'
386
+ : null;
387
+ const allWarnings = [...cliWarnings];
388
+ if (submittedWarning !== null) allWarnings.unshift(submittedWarning);
389
+ if (allWarnings.length > 0) {
390
+ out.warning = allWarnings.join(' ');
391
+ out.warnings = allWarnings;
201
392
  }
202
393
  console.log(JSON.stringify(out, null, 2));
203
394
  if (result.status !== 'succeeded' && result.status !== 'skipped' && result.status !== 'retried' && result.status !== 'submitted') {
@@ -213,11 +404,28 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
213
404
  if (result.artifactId) console.log(` Artifact ID: ${result.artifactId}`);
214
405
  if (result.candidateIds.length > 0) console.log(` Candidate IDs: ${result.candidateIds.join(', ')}`);
215
406
  if (result.ledgerEntryIds.length > 0) console.log(` Ledger Entry IDs: ${result.ledgerEntryIds.join(', ')}`);
407
+ if (result.progress && result.progress.seededTaskIds.length > 0) {
408
+ console.log(` Seeded Task IDs: ${result.progress.seededTaskIds.join(', ')}`);
409
+ }
216
410
  console.log(` Reason: ${opts.reason}`);
217
411
  console.log(` Score: ${opts.score ?? 80}`);
218
412
  console.log(` Source: ${opts.source ?? 'manual'}`);
219
413
  console.log(` Workspace: ${workspaceDir}`);
414
+ if (binding.sessionId) {
415
+ console.log(` Session: ${binding.sessionId} (bound, ${binding.evidence.length} evidence entries)`);
416
+ } else {
417
+ console.log(' Session: unbound (Owner report; no trajectory evidence)');
418
+ }
419
+ if (result.admissionResults && result.admissionResults.length > 0) {
420
+ console.log(` Admission: ${admittedCount} of ${result.admissionResults.length} candidates admitted`);
421
+ for (const r of result.admissionResults) {
422
+ console.log(` - ${r.candidateId}: ${r.admission.decision} (${r.admission.reason})`);
423
+ }
424
+ }
220
425
  if (result.latencyMs !== undefined) console.log(` Latency: ${result.latencyMs}ms`);
426
+ for (const w of cliWarnings) {
427
+ console.warn(` ⚠️ ${w}`);
428
+ }
221
429
  console.log(`\nDiagnostician pipeline running. Check progress with:`);
222
430
  console.log(` pd task show ${result.taskId} --workspace "${workspaceDir}"`);
223
431
  } else if (result.status === 'submitted') {
package/src/index.ts CHANGED
@@ -122,7 +122,7 @@ painCmd
122
122
  .option('-s, --score <number>', 'Pain score 0-100', parseInt)
123
123
  .option('-S, --source <text>', 'Source of the pain signal', 'manual')
124
124
  .option('-w, --workspace <path>', 'Workspace directory')
125
- .option('--session <id>', 'OpenClaw session ID for trajectory evidence extraction')
125
+ .option('--session <id>', 'Session ID to bind (validated against this workspace\'s trajectory.db; without it the record is unbound: no trajectory evidence, candidates likely gated by the admission threshold)')
126
126
  .option('--wait', 'Wait for diagnosis to complete (sync mode, overrides async flag)')
127
127
  .option('--json', 'Output raw JSON')
128
128
  .action(async (opts) => {