@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.
- package/dist/commands/build-trajectory-evidence.d.ts +10 -0
- package/dist/commands/build-trajectory-evidence.d.ts.map +1 -1
- package/dist/commands/build-trajectory-evidence.js +12 -0
- package/dist/commands/build-trajectory-evidence.js.map +1 -1
- package/dist/commands/pain-record.d.ts.map +1 -1
- package/dist/commands/pain-record.js +63 -29
- package/dist/commands/pain-record.js.map +1 -1
- package/dist/commands/runtime-init.d.ts.map +1 -1
- package/dist/commands/runtime-init.js +4 -6
- package/dist/commands/runtime-init.js.map +1 -1
- package/dist/commands/runtime-internalization-run-rulehost.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-rulehost.js +21 -1
- package/dist/commands/runtime-internalization-run-rulehost.js.map +1 -1
- package/dist/services/demo-rule-compiler.d.ts +9 -11
- package/dist/services/demo-rule-compiler.d.ts.map +1 -1
- package/dist/services/demo-rule-compiler.js +11 -37
- package/dist/services/demo-rule-compiler.js.map +1 -1
- package/dist/services/rulehost-pipeline-runner.d.ts +6 -9
- package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
- package/dist/services/rulehost-pipeline-runner.js +6 -9
- package/dist/services/rulehost-pipeline-runner.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/build-trajectory-evidence.ts +22 -0
- package/src/commands/pain-record.ts +64 -29
- package/src/commands/runtime-init.ts +4 -6
- package/src/commands/runtime-internalization-run-rulehost.ts +22 -1
- package/src/services/demo-rule-compiler.ts +12 -45
- package/src/services/rulehost-pipeline-runner.ts +6 -9
- package/tests/commands/build-trajectory-evidence.test.ts +106 -0
- package/tests/commands/pain-record-session-parser.test.ts +62 -12
- 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
|
|
95
|
-
* empty_trajectory /
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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:
|
|
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.
|
|
206
|
-
// future-added reason
|
|
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
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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`.
|
|
6
|
-
* to perform REAL canActivate validation instead of always returning success.
|
|
6
|
+
* functions for use in `evaluateInRefinerSandbox`.
|
|
7
7
|
*
|
|
8
|
-
* ARCHITECTURE:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
|
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`.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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`
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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', '
|
|
164
|
-
'--session', '
|
|
193
|
+
'--reason', 'PRI-783 review P1 lock',
|
|
194
|
+
'--session', 'fake-unverified-id',
|
|
165
195
|
'--workspace', tmpDir,
|
|
166
196
|
'--json',
|
|
167
197
|
]);
|
|
168
198
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
expect(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
expect(
|
|
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
|
});
|