@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.
@@ -569,6 +569,28 @@ describe('CLI command wiring (pd console open)', () => {
569
569
  fs.writeFileSync(path.join(consoleDir, 'package.json'), JSON.stringify({ name: 'fake-pd-console', version: '0.0.0', type: 'module' }, null, 2));
570
570
  // EP-06 regression guard: dist/web/index.html must exist (PR #1169 fix)
571
571
  fs.writeFileSync(path.join(consoleDir, 'dist', 'web', 'index.html'), '<!DOCTYPE html><html></html>');
572
+ // Runtime dependency integrity check: fake console needs minimal node_modules
573
+ // slots so the launcher passes the integrity gate and reaches spawn/health logic.
574
+ const nm = path.join(consoleDir, 'node_modules');
575
+ // principles-disciple: server imports principles-disciple/governance-audit
576
+ fs.mkdirSync(path.join(nm, 'principles-disciple', 'dist'), { recursive: true });
577
+ fs.writeFileSync(path.join(nm, 'principles-disciple', 'package.json'), JSON.stringify({ name: 'x', version: '0.0.0' }, null, 2));
578
+ fs.writeFileSync(path.join(nm, 'principles-disciple', 'dist', 'governance-audit.js'), 'export {};\n');
579
+ // @principles/core: server imports TWO subpaths
580
+ // @principles/core/principle-tree-ledger → dist/principle-tree-ledger.js
581
+ // @principles/core/runtime-v2 → dist/runtime-v2/index.js
582
+ fs.mkdirSync(path.join(nm, '@principles', 'core', 'dist', 'runtime-v2'), { recursive: true });
583
+ fs.writeFileSync(path.join(nm, '@principles', 'core', 'package.json'), JSON.stringify({ name: 'x', version: '0.0.0' }, null, 2));
584
+ fs.writeFileSync(path.join(nm, '@principles', 'core', 'dist', 'principle-tree-ledger.js'), 'export {};\n');
585
+ fs.writeFileSync(path.join(nm, '@principles', 'core', 'dist', 'runtime-v2', 'index.js'), 'export {};\n');
586
+ // @principles/host-runtime: uses root entry dist/index.js
587
+ fs.mkdirSync(path.join(nm, '@principles', 'host-runtime', 'dist'), { recursive: true });
588
+ fs.writeFileSync(path.join(nm, '@principles', 'host-runtime', 'package.json'), JSON.stringify({ name: 'x', version: '0.0.0' }, null, 2));
589
+ fs.writeFileSync(path.join(nm, '@principles', 'host-runtime', 'dist', 'index.js'), 'export {};\n');
590
+ // @principles/install-layout: uses root entry dist/index.js
591
+ fs.mkdirSync(path.join(nm, '@principles', 'install-layout', 'dist'), { recursive: true });
592
+ fs.writeFileSync(path.join(nm, '@principles', 'install-layout', 'package.json'), JSON.stringify({ name: 'x', version: '0.0.0' }, null, 2));
593
+ fs.writeFileSync(path.join(nm, '@principles', 'install-layout', 'dist', 'index.js'), 'export {};\n');
572
594
  fs.writeFileSync(path.join(consoleDir, 'dist', 'server.js'), `
573
595
  import http from 'node:http';
574
596
  const args = process.argv.slice(2);
@@ -710,6 +732,48 @@ describe('CLI command wiring (pd console open)', () => {
710
732
  }
711
733
  }, 20_000);
712
734
 
735
+ it('reuses a healthy running Console even when local dependency slots are broken (reuse-before-integrity contract)', async () => {
736
+ // Regression for the control-flow contract: the integrity gate must only
737
+ // run on the fresh-spawn branch. If a healthy Console is already running,
738
+ // broken on-disk node_modules (dangling junction / empty shell left by an
739
+ // interrupted update) must NOT prevent reusing it — its modules are loaded
740
+ // in memory. This test breaks the @principles/core slot on disk, then
741
+ // verifies the CLI still returns status "reused" (never hits the gate).
742
+ const nm = path.join(
743
+ process.env.__PD_CONSOLE_TEST_FAKE_HOME ?? '',
744
+ '.openclaw', 'extensions', 'principles-disciple', 'console', 'node_modules',
745
+ );
746
+ // Simulate a broken install: remove the @principles/core dist entry entirely.
747
+ const coreDist = path.join(nm, '@principles', 'core', 'dist');
748
+ if (fs.existsSync(coreDist)) fs.rmSync(coreDist, { recursive: true, force: true });
749
+
750
+ const server = http.createServer((req, res) => {
751
+ if (req.url === '/api/health') {
752
+ res.statusCode = 200;
753
+ res.end(JSON.stringify({ success: true, data: { authenticationMode: 'no_auth' } }));
754
+ return;
755
+ }
756
+ res.statusCode = 404;
757
+ res.end();
758
+ });
759
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
760
+ const addr = server.address();
761
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
762
+ let run: CliJsonRun | undefined;
763
+ try {
764
+ run = await runPdUntilJson(
765
+ ['console', 'open', '--workspace', tmp, '--port', String(addr.port), '--json', '--no-browser'],
766
+ workspaceRoot,
767
+ );
768
+ if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
769
+ expect(run.parsed.status).toBe('reused');
770
+ expect(run.parsed.reused).toBe(true);
771
+ } finally {
772
+ await teardownCliTree(run);
773
+ server.close();
774
+ }
775
+ }, 20_000);
776
+
713
777
  it('pd console open --port 99999 --json returns a structured failure (invalid port)', () => {
714
778
  const out = runPd(['console', 'open', '--workspace', tmp, '--port', '99999', '--json', '--no-browser'], workspaceRoot);
715
779
  const parsed = JSON.parse(out);
@@ -1115,3 +1179,114 @@ function killTreeForce(child: childProcessModule.ChildProcess): void {
1115
1179
  child.kill('SIGKILL');
1116
1180
  } catch { /* already gone */ }
1117
1181
  }
1182
+
1183
+ import { checkConsoleRuntimeDependencies } from '../../src/commands/console.js';
1184
+
1185
+ describe('checkConsoleRuntimeDependencies', () => {
1186
+ it('returns undefined when all deps are intact', () => {
1187
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'console-integ-'));
1188
+ try {
1189
+ // @principles/core: entries are dist/principle-tree-ledger.js + dist/runtime-v2/index.js
1190
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2'), { recursive: true });
1191
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'package.json'), '{}');
1192
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'principle-tree-ledger.js'), '');
1193
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2', 'index.js'), '');
1194
+ // @principles/host-runtime: entry is dist/index.js
1195
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'host-runtime', 'dist'), { recursive: true });
1196
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'host-runtime', 'package.json'), '{}');
1197
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'host-runtime', 'dist', 'index.js'), '');
1198
+ // @principles/install-layout: entry is dist/index.js
1199
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist'), { recursive: true });
1200
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'package.json'), '{}');
1201
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist', 'index.js'), '');
1202
+ // principles-disciple: entry is dist/governance-audit.js
1203
+ fs.mkdirSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist'), { recursive: true });
1204
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'package.json'), '{}');
1205
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist', 'governance-audit.js'), '');
1206
+
1207
+ expect(checkConsoleRuntimeDependencies(tmp)).toBeUndefined();
1208
+ } finally {
1209
+ fs.rmSync(tmp, { recursive: true, force: true });
1210
+ }
1211
+ });
1212
+
1213
+ it('returns error when a dep is missing package.json', () => {
1214
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'console-integ-'));
1215
+ try {
1216
+ // Create all deps intact except host-runtime (no package.json)
1217
+ // core
1218
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2'), { recursive: true });
1219
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'package.json'), '{}');
1220
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'principle-tree-ledger.js'), '');
1221
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2', 'index.js'), '');
1222
+ // install-layout
1223
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist'), { recursive: true });
1224
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'package.json'), '{}');
1225
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist', 'index.js'), '');
1226
+ // principles-disciple
1227
+ fs.mkdirSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist'), { recursive: true });
1228
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'package.json'), '{}');
1229
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist', 'governance-audit.js'), '');
1230
+ // @principles/host-runtime without package.json — the broken-shell case
1231
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'host-runtime', 'dist'), { recursive: true });
1232
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'host-runtime', 'dist', 'index.js'), '');
1233
+
1234
+ const err = checkConsoleRuntimeDependencies(tmp);
1235
+ expect(err).toContain('host-runtime');
1236
+ expect(err).toContain('package.json');
1237
+ } finally {
1238
+ fs.rmSync(tmp, { recursive: true, force: true });
1239
+ }
1240
+ });
1241
+
1242
+ it('returns error when a dep has package.json but no dist entry (empty shell)', () => {
1243
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'console-integ-'));
1244
+ try {
1245
+ // core intact
1246
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2'), { recursive: true });
1247
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'package.json'), '{}');
1248
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'principle-tree-ledger.js'), '');
1249
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2', 'index.js'), '');
1250
+ // install-layout intact
1251
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist'), { recursive: true });
1252
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'package.json'), '{}');
1253
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist', 'index.js'), '');
1254
+ // principles-disciple intact
1255
+ fs.mkdirSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist'), { recursive: true });
1256
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'package.json'), '{}');
1257
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist', 'governance-audit.js'), '');
1258
+ // host-runtime exists but has no dist/index.js (root entry missing)
1259
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'host-runtime'));
1260
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'host-runtime', 'package.json'), '{}');
1261
+
1262
+ const err = checkConsoleRuntimeDependencies(tmp);
1263
+ expect(err).toContain('host-runtime');
1264
+ expect(err).toContain('dist/index.js');
1265
+ } finally {
1266
+ fs.rmSync(tmp, { recursive: true, force: true });
1267
+ }
1268
+ });
1269
+
1270
+ it('returns error when a dep junction is dangling (directory missing)', () => {
1271
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'console-integ-'));
1272
+ try {
1273
+ // Create core, install-layout, principles-disciple; leave host-runtime missing
1274
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2'), { recursive: true });
1275
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'package.json'), '{}');
1276
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'principle-tree-ledger.js'), '');
1277
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'core', 'dist', 'runtime-v2', 'index.js'), '');
1278
+ fs.mkdirSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist'), { recursive: true });
1279
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'package.json'), '{}');
1280
+ fs.writeFileSync(path.join(tmp, 'node_modules', '@principles', 'install-layout', 'dist', 'index.js'), '');
1281
+ fs.mkdirSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist'), { recursive: true });
1282
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'package.json'), '{}');
1283
+ fs.writeFileSync(path.join(tmp, 'node_modules', 'principles-disciple', 'dist', 'governance-audit.js'), '');
1284
+
1285
+ const err = checkConsoleRuntimeDependencies(tmp);
1286
+ expect(err).toContain('host-runtime');
1287
+ expect(err).toContain('package.json');
1288
+ } finally {
1289
+ fs.rmSync(tmp, { recursive: true, force: true });
1290
+ }
1291
+ });
1292
+ });
@@ -31,36 +31,42 @@ vi.mock('../../src/commands/build-trajectory-evidence.js', () => ({
31
31
  ]),
32
32
  }));
33
33
 
34
- vi.mock('@principles/core/runtime-v2', () => ({
35
- PainToPrincipleService: vi.fn().mockImplementation(function(this: Record<string, unknown>, opts: Record<string, unknown>) {
36
- lastServiceOpts = opts;
37
- return {
38
- recordPain: vi.fn(async () => mockRecordPainResult),
39
- };
40
- }),
41
- PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
42
- computeEffectivePdConfig: vi.fn().mockReturnValue({
43
- runtimeKind: 'pi-ai',
44
- provider: 'test-provider',
45
- model: 'test-model',
46
- apiKeyEnv: 'TEST_KEY',
47
- timeoutMs: 300000,
48
- agentId: 'main',
49
- language: 'zh-CN',
50
- warnings: [],
51
- }),
52
- resolveRuntimeConfig: vi.fn().mockReturnValue({
53
- runtimeKind: 'pi-ai',
54
- provider: 'test-provider',
55
- model: 'test-model',
56
- apiKeyEnv: 'TEST_KEY',
57
- timeoutMs: 300000,
58
- agentId: 'main',
59
- }),
60
- isRuntimeConfigError: vi.fn().mockReturnValue(false),
61
- resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
62
- isFeatureEnabled: vi.fn().mockImplementation(() => mockIsFeatureEnabledReturn),
63
- }));
34
+ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
35
+ // PRI-642 review blocker 1: keep the REAL core evaluatePainIngress (the
36
+ // shared semantic authority) — only service/IO classes are mocked.
37
+ const actual = await importOriginal<typeof import('@principles/core/runtime-v2')>();
38
+ return {
39
+ ...actual,
40
+ PainToPrincipleService: vi.fn().mockImplementation(function(this: Record<string, unknown>, opts: Record<string, unknown>) {
41
+ lastServiceOpts = opts;
42
+ return {
43
+ recordPain: vi.fn(async () => mockRecordPainResult),
44
+ };
45
+ }),
46
+ PrincipleTreeLedgerAdapter: vi.fn().mockImplementation(function() { return {}; }),
47
+ computeEffectivePdConfig: vi.fn().mockReturnValue({
48
+ runtimeKind: 'pi-ai',
49
+ provider: 'test-provider',
50
+ model: 'test-model',
51
+ apiKeyEnv: 'TEST_KEY',
52
+ timeoutMs: 300000,
53
+ agentId: 'main',
54
+ language: 'zh-CN',
55
+ warnings: [],
56
+ }),
57
+ resolveRuntimeConfig: vi.fn().mockReturnValue({
58
+ runtimeKind: 'pi-ai',
59
+ provider: 'test-provider',
60
+ model: 'test-model',
61
+ apiKeyEnv: 'TEST_KEY',
62
+ timeoutMs: 300000,
63
+ agentId: 'main',
64
+ }),
65
+ isRuntimeConfigError: vi.fn().mockReturnValue(false),
66
+ resolveOutputLanguage: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
67
+ isFeatureEnabled: vi.fn().mockImplementation(() => mockIsFeatureEnabledReturn),
68
+ };
69
+ });
64
70
 
65
71
  vi.mock('../../src/services/pd-config-loader.js', () => ({
66
72
  loadPdConfig: vi.fn().mockReturnValue({
@@ -0,0 +1,127 @@
1
+ /**
2
+ * PRI-642 Scope A — real-Commander integration for `pd pain record --session`.
3
+ *
4
+ * Spawns the BUILT pd CLI (dist/index.js) against a real temp workspace with a
5
+ * real trajectory.db, exercising the actual commander registration and the
6
+ * real typed acquisition path — not the in-process handler with mocks
7
+ * (cli-7-test-wiring; SPEC §13 "Real Commander parser/registration tests for
8
+ * --session and --json").
9
+ */
10
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
11
+ import { promisify } from 'node:util';
12
+ import Database from 'better-sqlite3';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as os from 'node:os';
16
+
17
+ let tmpDir: string;
18
+
19
+ function createWorkspace(): void {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pain-record-parser-'));
21
+ const stateDir = path.join(tmpDir, '.state');
22
+ fs.mkdirSync(stateDir, { recursive: true });
23
+
24
+ const db = new Database(path.join(stateDir, 'trajectory.db'));
25
+ db.exec("CREATE TABLE IF NOT EXISTS sessions (session_id TEXT PRIMARY KEY, started_at TEXT, updated_at TEXT)");
26
+ 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
+ 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
+ db.prepare('INSERT INTO sessions (session_id, started_at, updated_at) VALUES (?, ?, ?)')
30
+ .run('real-session-1', '2026-01-01T09:00:00Z', '2026-01-01T09:00:00Z');
31
+ db.prepare('INSERT INTO user_turns (session_id, raw_excerpt, correction_detected, created_at) VALUES (?, ?, ?, ?)')
32
+ .run('real-session-1', 'Please fix the output format', 1, '2026-01-01T09:59:00Z');
33
+ db.close();
34
+ }
35
+
36
+ interface CliRunResult {
37
+ status: number | null;
38
+ stdout: string;
39
+ stderr: string;
40
+ }
41
+
42
+ /** Resolves and boundary-validates the built pd CLI entry before spawning it. */
43
+ async function resolveBuiltCliEntry(): Promise<string> {
44
+ const packageRoot = path.resolve(process.cwd());
45
+ const entry = path.resolve(packageRoot, 'dist', 'index.js');
46
+ if (!entry.startsWith(`${packageRoot}${path.sep}`) || !fs.statSync(entry).isFile()) {
47
+ throw new Error(`built pd CLI entry not found or outside the package: ${entry}`);
48
+ }
49
+ return entry;
50
+ }
51
+
52
+ async function runBuiltCli(literalArgv: readonly string[]): Promise<CliRunResult> {
53
+ const { execFile } = await import('node:child_process');
54
+ const execFileAsync = promisify(execFile);
55
+ const entry = await resolveBuiltCliEntry();
56
+ try {
57
+ const { stdout } = await execFileAsync(process.execPath, [entry, ...literalArgv], {
58
+ cwd: tmpDir,
59
+ timeout: 30000,
60
+ encoding: 'utf8',
61
+ env: { ...process.env, PD_WORKSPACE_DIR: tmpDir },
62
+ });
63
+ return { status: 0, stdout, stderr: '' };
64
+ } catch (err) {
65
+ const e = err as { code?: number; stdout?: string; stderr?: string };
66
+ return { status: e.code ?? -1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
67
+ }
68
+ }
69
+
70
+ describe('pd pain record --session (real Commander + real trajectory.db)', () => {
71
+ beforeEach(() => {
72
+ createWorkspace();
73
+ });
74
+
75
+ afterEach(() => {
76
+ try {
77
+ fs.rmSync(tmpDir, { recursive: true, force: true });
78
+ } catch {
79
+ // ignore cleanup errors
80
+ }
81
+ });
82
+
83
+ it('--help registers and documents --session', async () => {
84
+ const result = await runBuiltCli(['pain', 'record', '--help']);
85
+ expect(result.status).toBe(0);
86
+ expect(result.stdout).toContain('--session');
87
+ }, 15_000);
88
+
89
+ it('fails with a single JSON object and reason session_not_found for a nonexistent session (SPEC 12.1.4)', async () => {
90
+ const result = await runBuiltCli([
91
+ 'pain', 'record',
92
+ '--reason', 'parser test pain',
93
+ '--session', 'no-such-session',
94
+ '--workspace', tmpDir,
95
+ '--json',
96
+ ]);
97
+
98
+ // Failed validation exits non-zero.
99
+ expect(result.status).not.toBe(0);
100
+ // cli-1: stdout is exactly one JSON object.
101
+ const trimmed = result.stdout.trim();
102
+ expect(trimmed.length).toBeGreaterThan(0);
103
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
104
+ expect(parsed.status).toBe('failed');
105
+ expect(parsed.reason).toBe('session_not_found');
106
+ expect(typeof parsed.nextAction).toBe('string');
107
+ }, 15_000);
108
+
109
+ it('accepts --session for a session that exists in the trajectory (validation passes, submission proceeds)', async () => {
110
+ const result = await runBuiltCli([
111
+ 'pain', 'record',
112
+ '--reason', 'parser test pain with real session',
113
+ '--session', 'real-session-1',
114
+ '--workspace', tmpDir,
115
+ '--json',
116
+ ]);
117
+
118
+ // The session exists, so session validation must NOT reject it. The run
119
+ // may still fail later (no LLM runtime configured in this temp
120
+ // workspace) — the assertion is only about the validation stage.
121
+ const trimmed = result.stdout.trim();
122
+ expect(trimmed.length).toBeGreaterThan(0);
123
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
124
+ expect(parsed.reason).not.toBe('session_not_found');
125
+ expect(parsed.reason).not.toBe('empty_trajectory');
126
+ }, 15_000);
127
+ });