@principles/pd-cli 1.152.5 → 1.152.7

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 (31) hide show
  1. package/dist/commands/build-trajectory-evidence.d.ts +10 -0
  2. package/dist/commands/build-trajectory-evidence.d.ts.map +1 -1
  3. package/dist/commands/build-trajectory-evidence.js +12 -0
  4. package/dist/commands/build-trajectory-evidence.js.map +1 -1
  5. package/dist/commands/pain-record.d.ts.map +1 -1
  6. package/dist/commands/pain-record.js +63 -29
  7. package/dist/commands/pain-record.js.map +1 -1
  8. package/dist/commands/runtime-init.d.ts.map +1 -1
  9. package/dist/commands/runtime-init.js +4 -6
  10. package/dist/commands/runtime-init.js.map +1 -1
  11. package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
  12. package/dist/commands/runtime-internalization-run-rulehost.js +21 -1
  13. package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
  14. package/dist/services/demo-rule-compiler.d.ts +9 -11
  15. package/dist/services/demo-rule-compiler.d.ts.map +1 -1
  16. package/dist/services/demo-rule-compiler.js +11 -37
  17. package/dist/services/demo-rule-compiler.js.map +1 -1
  18. package/dist/services/rulehost-pipeline-runner.d.ts +6 -9
  19. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  20. package/dist/services/rulehost-pipeline-runner.js +6 -9
  21. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/commands/build-trajectory-evidence.ts +22 -0
  24. package/src/commands/pain-record.ts +64 -29
  25. package/src/commands/runtime-init.ts +4 -6
  26. package/src/commands/runtime-internalization-run-rulehost.ts +22 -1
  27. package/src/services/demo-rule-compiler.ts +12 -45
  28. package/src/services/rulehost-pipeline-runner.ts +6 -9
  29. package/tests/commands/build-trajectory-evidence.test.ts +106 -0
  30. package/tests/commands/pain-record-session-parser.test.ts +62 -12
  31. package/tests/commands/pain-record.test.ts +59 -19
@@ -87,18 +87,29 @@ function toIngressEntry(entry: PainEvidenceEntry): IngressEvidenceEntry {
87
87
  * PRI-642 (SPEC §7.3/§7.4, review blocker 1): build the report from this
88
88
  * adapter's host-specific facts and let the SHARED evaluator decide.
89
89
  *
90
- * Per-channel policy (SPEC §7.3 vs OpenClaw):
90
+ * Per-channel policy (SPEC §7.3/§8.2 vs OpenClaw):
91
91
  *
92
92
  * - explicit `--session` + acquisition available (real session + real entries):
93
93
  * → cli_explicit_session + bound → submit with real evidence.
94
- * - explicit `--session` + acquisition unavailable (session_not_found /
95
- * empty_trajectory / trajectory_unavailable / evidence_read_failed):
96
- * → cli_explicit_session + unbound → shared evaluator REFUSES
97
- * (row 5) before any LLM/task/candidate mutation.
98
- * The CLI does NOT share the OpenClaw funnel's "degrade" semantics
99
- * because the CLI does not own the session identity the way the host
100
- * command context does (Evidence Over Assumption — an unverified
101
- * session claimed as bound would be a PRI-642 recurrence).
94
+ * - explicit `--session` + acquisition unavailable with the binding VERIFIED
95
+ * (empty_trajectory / evidence-table-level evidence_read_failed — the
96
+ * sessions table was queried and DID contain the id; SPEC §8.2: binding
97
+ * failure and evidence failure are separate axes, a real session with
98
+ * unusable evidence is bound + unavailable, not unbound):
99
+ * → cli_explicit_session + bound → shared evaluator DEGRADES (row 4):
100
+ * submit with honest empty evidence; the admission gate owns what
101
+ * empty evidence is worth. PRI-783 Owner directive: a correct current
102
+ * session id is sufficient — empty evidence must not hard-refuse.
103
+ * - explicit `--session` + acquisition unavailable with the binding
104
+ * UNVERIFIED (session_not_found / trajectory_unavailable / DB-open-level
105
+ * evidence_read_failed — SPEC §8.2's "real session" premise does not
106
+ * hold: the CLI verifies session ids against trajectory.db itself, so
107
+ * when that DB is missing or unopenable the id is unproven):
108
+ * → cli_explicit_session + unbound → shared evaluator REFUSES (row 5)
109
+ * before any LLM/task/candidate mutation. Claiming bound here would
110
+ * also let the observability writer persist the unverified id into a
111
+ * freshly created trajectory.db — laundering an unproven fact into
112
+ * future "evidence" (PRI-783 review P1, self-proving loop).
102
113
  * - no `--session` → unbound Owner report (matrix row 6) → submit with
103
114
  * disclosure; no sentinel session, no placeholder evidence, no trajectory
104
115
  * projection (the CLI skips observability when the ingress yields no
@@ -159,25 +170,38 @@ function resolveIngressDecision(
159
170
  return { decision, acquisitionDetail: null, acquisitionReason: null };
160
171
  }
161
172
 
162
- // --session: SPEC §7.3 demands a fail-loud refusal whenever the CLI
163
- // cannot actually verify the claimed binding. We do not share the
164
- // OpenClaw funnel's "degrade" semantics here because the CLI does
165
- // not own the session identity the way the host command context does
166
- // (Evidence Over Assumption): an unverified session that we still
167
- // claimed as bound would be a PRI-642 recurrence in a different
168
- // shape. Per-channel policy differs; semantic authority is shared.
173
+ // --session: SPEC §7.3/§8.2. Binding failure and evidence failure are
174
+ // separate axes, and the acquisition result carries the binding fact
175
+ // directly (PRI-783 review): 'verified' = the sessions table was queried
176
+ // and DID contain the id; 'unverified' = missing/unopenable DB or absent
177
+ // id. Only a VERIFIED binding may degrade to a bound submission (row 4);
178
+ // an unverified one is refused (row 5) — claiming bound for an unverified
179
+ // id would let the observability writer persist that id into a freshly
180
+ // created trajectory.db (self-proving loop). Per-channel policy differs;
181
+ // semantic authority is shared.
169
182
  const acquisition = acquireTrajectoryEvidenceFromDb(stateDir, opts.session, workspaceDir);
170
183
  if (acquisition.status === 'unavailable') {
171
- // session_not_found / empty_trajectory / trajectory_unavailable /
172
- // evidence_read_failed — refuse (row 5) before any LLM/task/candidate
173
- // mutation. The CLI surfaces the reasonCode with a SPEC §7.3 next
174
- // action; the evaluator produces the refuse decision.
175
- const {reasonCode} = acquisition;
184
+ const { reasonCode, binding } = acquisition;
185
+ if (binding === 'verified') {
186
+ // empty_trajectory / evidence-table-level evidence_read_failed —
187
+ // bound + unavailable (row 4): degrade to an honest empty-evidence
188
+ // submission; the admission gate decides what empty evidence is worth.
189
+ const decision = evaluatePainIngress({
190
+ ...base,
191
+ origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
192
+ correlation: { status: 'bound', hostKind: 'openclaw', sessionId: opts.session },
193
+ evidence: { status: 'unavailable', reason: reasonCode },
194
+ });
195
+ return { decision, acquisitionDetail: acquisition.detail, acquisitionReason: reasonCode };
196
+ }
197
+ // binding 'unverified' (session_not_found / trajectory_unavailable /
198
+ // DB-open-level evidence_read_failed) — the binding claim could not be
199
+ // validated: refuse (row 5) before any mutation.
176
200
  const decision = evaluatePainIngress({
177
201
  ...base,
178
202
  origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
179
203
  correlation: { status: 'unbound', reason: 'external_cli' },
180
- evidence: { status: 'unavailable', reason: reasonCode === 'session_not_found' ? 'not_applicable_unbound' : reasonCode },
204
+ evidence: { status: 'unavailable', reason: 'not_applicable_unbound' },
181
205
  });
182
206
  if (decision.action === 'refuse') {
183
207
  return { decision, acquisitionDetail: acquisition.detail, acquisitionReason: reasonCode };
@@ -202,8 +226,9 @@ function resolveIngressDecision(
202
226
  }
203
227
 
204
228
  // Exhaustive — acquireTrajectoryEvidenceFromDb returns {available, unavailable}
205
- // only. The empty-trajectory branch was collapsed to refuse above; a
206
- // future-added reason would land here and must also refuse.
229
+ // only. Unavailable either returned above (degrade for verified bindings,
230
+ // refuse for unverified ones); a future-added reason must be classified
231
+ // explicitly here rather than silently degrading.
207
232
  const decision = evaluatePainIngress({
208
233
  ...base,
209
234
  origin: { kind: 'owner_manual', channel: 'cli_explicit_session' },
@@ -315,18 +340,28 @@ export async function handlePainRecord(opts: RecordOptions): Promise<void> {
315
340
  const { decision, acquisitionDetail, acquisitionReason } = resolveIngressDecision({ opts, stateDir, workspaceDir, painId });
316
341
 
317
342
  if (decision.action === 'refuse') {
318
- // SPEC §7.3: the CLI must surface the specific acquisition reason
319
- // (session_not_found / empty_trajectory / trajectory_unavailable /
320
- // evidence_read_failed) — the shared evaluator produces the refuse
321
- // decision; we map the acquisition detail to the Operator reason.
343
+ // SPEC §7.3: the CLI must surface the specific acquisition reason —
344
+ // the shared evaluator produces the refuse decision; we map the
345
+ // acquisition detail to the Operator reason. PRI-783: the evaluator's
346
+ // generic next action suggests `pd pain record --session` — the exact
347
+ // path that just failed — so every CLI acquisition refuse gets honest,
348
+ // reason-specific guidance instead.
322
349
  const reason = opts.session !== undefined && acquisitionReason !== null
323
350
  ? acquisitionReason
324
351
  : decision.reasonCode;
325
352
  const detailSuffix = acquisitionDetail !== null ? ` (${acquisitionDetail})` : '';
353
+ const nextAction =
354
+ reason === 'session_not_found'
355
+ ? 'Check the session id — it is not present in this workspace trajectory — or drop --session to record an unbound Owner report.'
356
+ : reason === 'trajectory_unavailable'
357
+ ? 'No trajectory.db exists in this workspace, so the session id could not be verified — point --workspace at the workspace that owns the session, or drop --session to record an unbound Owner report.'
358
+ : reason === 'evidence_read_failed'
359
+ ? 'The workspace trajectory could not be read, so the session id could not be verified — resolve access to .state/trajectory.db, or drop --session to record an unbound Owner report.'
360
+ : decision.nextAction;
326
361
  emitSessionBindingFailure(opts, {
327
362
  reason,
328
363
  message: `${decision.warning}${detailSuffix}`,
329
- nextAction: decision.nextAction,
364
+ nextAction,
330
365
  });
331
366
  return;
332
367
  }
@@ -25,6 +25,7 @@ import * as yaml from 'js-yaml';
25
25
  import { SqliteConnection, SchemaConformanceReadModel } from '@principles/core/runtime-v2';
26
26
  import { getDefaultPdConfig, validatePdConfig } from '@principles/core/runtime-v2';
27
27
  import { initTrajectorySchema } from 'principles-disciple';
28
+ import { TRAJECTORY_TABLES } from '@principles/core/runtime-v2';
28
29
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
29
30
  import { emitResult, emitFlagConflict, emitError } from '../services/cli-output.js';
30
31
 
@@ -251,12 +252,9 @@ export function buildRuntimeInitOutput(workspaceDir: string, confirm: boolean):
251
252
  databases.push({
252
253
  name: DB_NAMES.trajectory,
253
254
  path: path.join(resolvedWorkspace, '.state', 'trajectory.db'),
254
- tables: ['schema_version', 'ingest_checkpoint', 'sessions', 'assistant_turns',
255
- 'user_turns', 'tool_calls', 'pain_events', 'gate_blocks', 'trust_changes',
256
- 'principle_events', 'task_outcomes', 'correction_samples', 'sample_reviews',
257
- // PRI-790 G2: Stage2 待确认信号的持久队列。
258
- 'signal_confirmations',
259
- 'exports_audit'],
255
+ // PRI-774: derived from TRAJECTORY_TABLES — same canonical source as
256
+ // the real init, so the dry-run report can never drift.
257
+ tables: [...TRAJECTORY_TABLES],
260
258
  status: 'skipped',
261
259
  warnings: [],
262
260
  });
@@ -39,6 +39,8 @@ import {
39
39
  } from '@principles/core/runtime-v2';
40
40
  import type { EffectivePdConfig, InternalAgentName, PDRuntimeAdapter } from '@principles/core/runtime-v2';
41
41
  import type { BehaviorExamplePack } from '@principles/core/runtime-v2';
42
+ import { storeEmitter } from '@principles/core/runtime-v2';
43
+ import { WorkspaceTelemetryEmitter } from '@principles/host-runtime';
42
44
  import { resolveRuntimeFromPdConfig } from '../services/resolve-runtime-from-pd-config.js';
43
45
  import { resolveRuleHostReadiness } from '../services/rulehost-readiness.js';
44
46
  import type { RuleHostReadinessResult } from '../services/rulehost-readiness.js';
@@ -254,6 +256,16 @@ function resolveRunRuleHostRuntime(
254
256
  agentRuntimeProfiles.artificer = artificerBinding.profileId;
255
257
  agentRuntimeProfiles.evaluator = evaluator.profileId;
256
258
 
259
+ // PRI-795 review P1: the CLI process is one-shot — without a durable sink
260
+ // the artificer_l2_complete evidence (abortOwner/budget/elapsed/stopReason/
261
+ // tokenUsage) dies with the process, repeating the EP002-R3
262
+ // "nothing recoverable after a failure" investigation gap. Reuse the
263
+ // host-runtime workspace-scoped emitter so completions land in
264
+ // <workspaceDir>/.pd/telemetry/critical-events.jsonl.
265
+ const artificerEmitter = new WorkspaceTelemetryEmitter(storeEmitter, workspaceDir, (detail) => {
266
+ console.error(`[run-rulehost] workspace telemetry persist failed: ${detail}`);
267
+ });
268
+
257
269
  const artificerAdapter = new ArtificerL2Adapter({
258
270
  provider: artificerProfile.provider,
259
271
  model: artificerProfile.model,
@@ -261,10 +273,19 @@ function resolveRunRuleHostRuntime(
261
273
  baseUrl: artificerProfile.baseUrl,
262
274
  gateDeps: createSandboxGateDeps(),
263
275
  validator: new DefaultArtificerValidator(),
264
- totalBudgetMs: timeoutMs,
276
+ // PRI-795: fall back to the profile's timeoutMs when --timeout-ms is not
277
+ // given — parity with resolvePiAiAgentAdapter above. Previously the raw
278
+ // CLI option (possibly undefined) was passed, silently dropping the L2
279
+ // loop onto the adapter's 300s default regardless of the profile.
280
+ totalBudgetMs: timeoutMs ?? artificerProfile.timeoutMs,
265
281
  // Forward profile maxTokens — parity with the consumer-cycle adapter;
266
282
  // omitting it truncates long artificer JSON at the provider's default cap.
267
283
  ...(artificerProfile.maxTokens !== undefined ? { maxTokens: artificerProfile.maxTokens } : {}),
284
+ // PRI-795: forward profile reasoning so always-thinking models get a
285
+ // bounded thinking level on the L2 loop too — parity with the
286
+ // PiAiRuntimeAdapter wiring (it was previously L2-dropped).
287
+ ...(artificerProfile.reasoning !== undefined ? { reasoning: artificerProfile.reasoning } : {}),
288
+ eventEmitter: artificerEmitter,
268
289
  });
269
290
 
270
291
  return {
@@ -1,63 +1,30 @@
1
1
  /**
2
- * Demo Rule Compiler — Minimal bridge for Story A demo runner
2
+ * Demo Rule Compiler — Minimal bridge for Story A demo runner and the
3
+ * rulehost pipeline behavior checks.
3
4
  *
4
5
  * PURPOSE: Compile rule implementation code strings into typed evaluate
5
- * functions for use in `evaluateInRefinerSandbox`. This allows the demo
6
- * to perform REAL canActivate validation instead of always returning success.
6
+ * functions for use in `evaluateInRefinerSandbox`.
7
7
  *
8
- * ARCHITECTURE: This intentionally duplicates the compilation logic from
9
- * `openclaw-plugin/src/core/rule-implementation-runtime.ts` because pd-cli
10
- * cannot depend on the bundled openclaw-plugin package. The compilation
11
- * logic is identical: normalize exports → vm compile → extract evaluate.
12
- *
13
- * NOT for production use — the openclaw-plugin's RuleHost uses its own
14
- * `loadRuleImplementationModule` for production code_tool_hook evaluation.
8
+ * ARCHITECTURE (PRI-809): compilation delegates to the canonical hardened
9
+ * replay evaluator in @principles/core — the vm realm with the JSON-string
10
+ * trust-boundary crossing (input + helpers rebuilt inside the realm), the
11
+ * same primitive the production gate uses. The previous local vm duplicate
12
+ * handed host-realm objects to the vm-realm evaluate function, which let
13
+ * unapproved rule code escape via `input.constructor.constructor`.
15
14
  */
16
15
 
17
- import * as vm from 'node:vm';
18
16
  import type { RuleHostInput, RuleHostResult } from '@principles/core/runtime-v2';
19
17
  import type { RuleHostHelpers } from '@principles/core/runtime-v2';
20
18
  import type { ReplayEvaluateFn } from '@principles/core/runtime-v2';
21
- import { safeStringifyPreview, validateRuleHostResult } from '@principles/core/runtime-v2';
22
-
23
- function normalizeSource(sourceCode: string): string {
24
- const withoutExports = sourceCode
25
- .replace(/export\s+const\s+meta\s*=/, 'const meta =')
26
- .replace(/export\s+function\s+evaluate\s*\(/, 'function evaluate(');
27
-
28
- return `${withoutExports}
29
- globalThis.__pdRuleModule = {
30
- meta: typeof meta === 'undefined' ? undefined : meta,
31
- evaluate: typeof evaluate === 'undefined' ? undefined : evaluate,
32
- };`;
33
- }
34
-
35
- function isRuleEvaluator(value: unknown): value is (input: RuleHostInput, helpers: RuleHostHelpers) => unknown {
36
- return typeof value === 'function';
37
- }
19
+ import { compileHardenedRuleEvaluator, safeStringifyPreview, validateRuleHostResult } from '@principles/core/runtime-v2';
38
20
 
39
21
  /**
40
22
  * Compile rule implementation code and return a typed evaluate function.
41
- * Mirrors `createReplayEvaluateFromCode` in openclaw-plugin.
42
23
  *
43
24
  * @throws if the code fails to compile or does not define a function evaluate
44
25
  */
45
26
  export function compileDemoRule(code: string, sourceLabel: string): ReplayEvaluateFn {
46
- const context = vm.createContext(Object.create(null));
47
- const script = new vm.Script(normalizeSource(code), {
48
- filename: sourceLabel,
49
- });
50
-
51
- script.runInContext(context, { timeout: 1000, displayErrors: true });
52
-
53
- const moduleExports = (context as { __pdRuleModule?: { meta?: unknown; evaluate?: unknown } }).__pdRuleModule;
54
- delete (context as { __pdRuleModule?: unknown }).__pdRuleModule;
55
-
56
- if (!moduleExports || !isRuleEvaluator(moduleExports.evaluate)) {
57
- throw new Error(`[compileDemoRule] ${sourceLabel}: compiled module has no evaluate function`);
58
- }
59
-
60
- const evaluateFn = moduleExports.evaluate;
27
+ const evaluateFn = compileHardenedRuleEvaluator(code, sourceLabel);
61
28
  return (input: RuleHostInput, helpers: RuleHostHelpers): RuleHostResult => {
62
29
  const result = evaluateFn(input, helpers);
63
30
  const validation = validateRuleHostResult(result);
@@ -68,6 +35,6 @@ export function compileDemoRule(code: string, sourceLabel: string): ReplayEvalua
68
35
  })`,
69
36
  );
70
37
  }
71
- return result as RuleHostResult;
38
+ return result;
72
39
  };
73
40
  }
@@ -17,10 +17,9 @@
17
17
  * - The artificer↔evaluator stage delegates to `runAdversarialLoop` (PRI-428),
18
18
  * which never throws and degrades to { decision: 'rejected' } with a reason.
19
19
  * - gateDeps (the sandbox adapter) is built from `compileDemoRule` +
20
- * `evaluateInRefinerSandbox`. Per the Explore finding, `compileDemoRule` is
21
- * byte-equivalent to openclaw-plugin's `loadRuleImplementationModule`; the
22
- * duplication is a package-boundary necessity (pd-cli cannot import
23
- * openclaw-plugin), not a capability gap.
20
+ * `evaluateInRefinerSandbox`. `compileDemoRule` delegates to the canonical
21
+ * hardened replay evaluator in @principles/core (PRI-809), so pre-activation
22
+ * replay crosses the same vm trust boundary as the production gate.
24
23
  *
25
24
  * @see docs/plans/rulehost-mvp-activation.md
26
25
  * @see runAdversarialLoop in @principles/core/runtime-v2
@@ -226,11 +225,9 @@ export interface RuleHostPipelineResult {
226
225
  /**
227
226
  * Build the sandbox gate deps for adversarial replay.
228
227
  *
229
- * `compileDemoRule` is byte-equivalent to openclaw-plugin's
230
- * `loadRuleImplementationModule` (same vm.createContext, same normalize, same
231
- * evaluate-shape check). pd-cli cannot import openclaw-plugin (package
232
- * boundary), so this duplicate is intentional and capability-identical, not a
233
- * stripped demo. See demo-rule-compiler.ts header comment.
228
+ * `compileDemoRule` delegates to the canonical hardened replay evaluator in
229
+ * @principles/core (PRI-809) — capability-identical across packages without a
230
+ * duplicate vm implementation. See demo-rule-compiler.ts header comment.
234
231
  */
235
232
  export function createSandboxGateDeps(): RefinerRuleHostGateDeps {
236
233
  return {
@@ -400,3 +400,109 @@ describe('acquireTrajectoryEvidenceFromDb — typed CLI acquisition (PRI-642 Sco
400
400
  expect(unreadable.reasonCode).toBe('evidence_read_failed');
401
401
  });
402
402
  });
403
+
404
+ // ── PRI-783 review P1: the binding fact gates degrade vs refuse ─────────────
405
+ // 'verified' = the sessions table was queried and DID contain the id;
406
+ // 'unverified' = DB missing / unopenable / id absent / sessions table
407
+ // unreadable. Only verified bindings may degrade to a bound submission.
408
+
409
+ describe('acquisition binding fact (PRI-783 review P1)', () => {
410
+ beforeEach(() => {
411
+ createStateDir();
412
+ });
413
+
414
+ afterEach(() => {
415
+ try {
416
+ fs.rmSync(tmpDir, { recursive: true, force: true });
417
+ } catch {
418
+ // ignore cleanup errors
419
+ }
420
+ });
421
+
422
+ it('session present with zero evidence → empty_trajectory, binding verified (degrade precondition)', async () => {
423
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
424
+ const db = createTrajectoryDb();
425
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
426
+ .run('quiet-session', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
427
+ insertUserTurn(db, 'quiet-session', 'ordinary message', false, '2026-01-01T09:01:00Z');
428
+ db.close();
429
+
430
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'quiet-session', tmpDir);
431
+
432
+ expect(result.status).toBe('unavailable');
433
+ if (result.status !== 'unavailable') return;
434
+ expect(result.reasonCode).toBe('empty_trajectory');
435
+ expect(result.binding).toBe('verified');
436
+ });
437
+
438
+ it('id absent from sessions table → session_not_found, binding unverified', async () => {
439
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
440
+ const db = createTrajectoryDb();
441
+ db.close();
442
+
443
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'no-such-session', tmpDir);
444
+
445
+ expect(result.status).toBe('unavailable');
446
+ if (result.status !== 'unavailable') return;
447
+ expect(result.reasonCode).toBe('session_not_found');
448
+ expect(result.binding).toBe('unverified');
449
+ });
450
+
451
+ it('missing trajectory.db → trajectory_unavailable, binding unverified', async () => {
452
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
453
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'some-session', tmpDir);
454
+
455
+ expect(result.status).toBe('unavailable');
456
+ if (result.status !== 'unavailable') return;
457
+ expect(result.reasonCode).toBe('trajectory_unavailable');
458
+ expect(result.binding).toBe('unverified');
459
+ });
460
+
461
+ it('corrupt DB (open fails) → evidence_read_failed, binding unverified', async () => {
462
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
463
+ const dbPath = path.join(stateDir, 'trajectory.db');
464
+ fs.writeFileSync(dbPath, Buffer.from('this is not a sqlite database at all'));
465
+
466
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'some-session', tmpDir);
467
+
468
+ expect(result.status).toBe('unavailable');
469
+ if (result.status !== 'unavailable') return;
470
+ expect(result.reasonCode).toBe('evidence_read_failed');
471
+ expect(result.binding).toBe('unverified');
472
+ });
473
+
474
+ it('session verified but evidence tables unreadable → evidence_read_failed, binding verified', async () => {
475
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
476
+ const db = createTrajectoryDb();
477
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
478
+ .run('readable-session', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
479
+ db.close();
480
+ // Drop the evidence tables after the session row exists: the sessions
481
+ // check passed, the evidence read cannot.
482
+ const raw = new Database(path.join(stateDir, 'trajectory.db'));
483
+ raw.exec('DROP TABLE user_turns');
484
+ raw.close();
485
+
486
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'readable-session', tmpDir);
487
+
488
+ expect(result.status).toBe('unavailable');
489
+ if (result.status !== 'unavailable') return;
490
+ expect(result.reasonCode).toBe('evidence_read_failed');
491
+ expect(result.binding).toBe('verified');
492
+ });
493
+
494
+ it('sessions table missing entirely → nothing is verified (unverified even when the reason is evidence_read_failed)', async () => {
495
+ const { acquireTrajectoryEvidenceFromDb } = await import('../../src/commands/build-trajectory-evidence.js');
496
+ const db = new Database(path.join(stateDir, 'trajectory.db'));
497
+ db.exec('CREATE TABLE user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, correction_cue TEXT, created_at TEXT NOT NULL)');
498
+ db.close();
499
+
500
+ const result = acquireTrajectoryEvidenceFromDb(stateDir, 'never-seen', tmpDir);
501
+
502
+ expect(result.status).toBe('unavailable');
503
+ if (result.status !== 'unavailable') return;
504
+ // assistant_turns/tool_calls tables missing → readFailed → evidence_read_failed
505
+ expect(result.reasonCode).toBe('evidence_read_failed');
506
+ expect(result.binding).toBe('unverified');
507
+ });
508
+ });
@@ -13,6 +13,7 @@ import Database from 'better-sqlite3';
13
13
  import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import * as os from 'node:os';
16
+ import { acquireTrajectoryEvidenceFromDb } from '../../src/commands/build-trajectory-evidence.js';
16
17
 
17
18
  let tmpDir: string;
18
19
 
@@ -24,7 +25,12 @@ function createWorkspace(): void {
24
25
  const db = new Database(path.join(stateDir, 'trajectory.db'));
25
26
  db.exec("CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, started_at TEXT, updated_at TEXT)");
26
27
  db.exec("CREATE TABLE IF NOT EXISTS assistant_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, sanitized_text TEXT, stop_reason TEXT, created_at TEXT NOT NULL)");
27
- db.exec("CREATE TABLE IF NOT EXISTS user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL)");
28
+ // PRI-783 lesson recorded on the fixture: user_turns MUST carry
29
+ // correction_cue — collectEvidenceFromDb SELECTs it, and a missing column
30
+ // silently degraded the user-turns read into evidence_read_failed, so this
31
+ // file's 'accepts --session' case never actually exercised a real evidence
32
+ // read (it vacuously asserted on a refusal).
33
+ db.exec("CREATE TABLE IF NOT EXISTS user_turns (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, raw_excerpt TEXT, correction_detected INTEGER NOT NULL DEFAULT 0, correction_cue TEXT, created_at TEXT NOT NULL)");
28
34
  db.exec("CREATE TABLE IF NOT EXISTS tool_calls (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, tool_name TEXT NOT NULL, outcome TEXT NOT NULL, error_type TEXT, exit_code INTEGER, params_json TEXT NOT NULL DEFAULT '{}', result_preview TEXT, created_at TEXT NOT NULL)");
29
35
  db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
30
36
  .run('real-session-1', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
@@ -157,22 +163,66 @@ describe('pd pain record --session (real Commander + real trajectory.db)', () =>
157
163
  expect(typeof parsed.nextAction).toBe('string');
158
164
  }, 15_000);
159
165
 
160
- it('accepts --session for a session that exists in the trajectory (validation passes, submission proceeds)', async () => {
166
+ it('accepts --session for a session that exists in the trajectory (real acquisition returns available evidence)', async () => {
167
+ // The spawned-CLI route is used above for the refusal contracts. The
168
+ // acceptance route ends in the diagnostician pipeline (out of this file's
169
+ // scope — the service has its own suite), so the acceptance boundary is
170
+ // asserted against the REAL acquisition function the CLI delegates to,
171
+ // over this file's real trajectory.db fixture (cli-7: real parser wiring
172
+ // is covered by the refusal spawns; real DB semantics here — PRI-783).
173
+ const stateDir = path.join(tmpDir, '.state');
174
+ const acquisition = acquireTrajectoryEvidenceFromDb(stateDir, 'real-session-1', tmpDir);
175
+
176
+ expect(acquisition.status).toBe('available');
177
+ if (acquisition.status !== 'available') return;
178
+ expect(acquisition.entries.length).toBeGreaterThanOrEqual(1);
179
+ expect(acquisition.entries.some(e => e.sourceRef.startsWith('owner_message:'))).toBe(true);
180
+ }, 15_000);
181
+
182
+ // ── PRI-783 review P1: unverified bindings must refuse, never persist ──────
183
+
184
+ it('locks the review P1: --session with a missing trajectory.db refuses and NEVER creates a db containing the fake id', async () => {
185
+ // Remove the fixture db so the id cannot be verified at all (the shared
186
+ // createWorkspace always seeds one). The run must fail loud
187
+ // (trajectory_unavailable) BEFORE any mutation — the observability writer
188
+ // must not create a trajectory.db and upsert the unverified id into
189
+ // sessions (self-proving loop).
190
+ fs.rmSync(path.join(tmpDir, '.state', 'trajectory.db'), { force: true });
161
191
  const result = await runBuiltCli([
162
192
  'pain', 'record',
163
- '--reason', 'parser test pain with real session',
164
- '--session', 'real-session-1',
193
+ '--reason', 'PRI-783 review P1 lock',
194
+ '--session', 'fake-unverified-id',
165
195
  '--workspace', tmpDir,
166
196
  '--json',
167
197
  ]);
168
198
 
169
- // The session exists, so session validation must NOT reject it. The run
170
- // may still fail later (no LLM runtime configured in this temp
171
- // workspace) — the assertion is only about the validation stage.
172
- const trimmed = result.stdout.trim();
173
- expect(trimmed.length).toBeGreaterThan(0);
174
- const parsed = JSON.parse(trimmed) as Record<string, unknown>;
175
- expect(parsed.reason).not.toBe('session_not_found');
176
- expect(parsed.reason).not.toBe('empty_trajectory');
199
+ expect(result.status).not.toBe(0);
200
+ const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
201
+ expect(parsed.status).toBe('failed');
202
+ expect(parsed.reason).toBe('trajectory_unavailable');
203
+ expect(parsed.nextAction).not.toMatch(/pd pain record --session/);
204
+ // The lock: no trajectory.db was created by this run — the fake id can
205
+ // never appear in a sessions table.
206
+ expect(fs.existsSync(path.join(tmpDir, '.state', 'trajectory.db'))).toBe(false);
207
+ }, 15_000);
208
+
209
+ it('real empty session reports empty_trajectory with a VERIFIED binding (the degrade precondition)', async () => {
210
+ // Gate the degrade path on the real acquisition semantics: a session
211
+ // row that exists with zero evidence rows is the only unavailable shape
212
+ // allowed to degrade to a bound submission.
213
+ const stateDir = path.join(tmpDir, '.state');
214
+ const db = new Database(path.join(stateDir, 'trajectory.db'));
215
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
216
+ .run('real-empty-session', '2026-01-01T08:00:00Z', '2026-01-01T08:00:00Z');
217
+ db.prepare('INSERT INTO user_turns (session_id, raw_excerpt, correction_detected, correction_cue, created_at) VALUES (?, ?, ?, ?, ?)')
218
+ .run('real-empty-session', 'ordinary message, not a correction', 0, null, '2026-01-01T08:01:00Z');
219
+ db.close();
220
+
221
+ const acquisition = acquireTrajectoryEvidenceFromDb(stateDir, 'real-empty-session', tmpDir);
222
+
223
+ expect(acquisition.status).toBe('unavailable');
224
+ if (acquisition.status !== 'unavailable') return;
225
+ expect(acquisition.reasonCode).toBe('empty_trajectory');
226
+ expect(acquisition.binding).toBe('verified');
177
227
  }, 15_000);
178
228
  });