@principles/pd-cli 1.135.2 → 1.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/commands/demo-story-a.d.ts +2 -0
  2. package/dist/commands/demo-story-a.d.ts.map +1 -1
  3. package/dist/commands/demo-story-a.js +41 -0
  4. package/dist/commands/demo-story-a.js.map +1 -1
  5. package/dist/commands/runtime-compatibility-scan.d.ts +32 -0
  6. package/dist/commands/runtime-compatibility-scan.d.ts.map +1 -0
  7. package/dist/commands/runtime-compatibility-scan.js +94 -0
  8. package/dist/commands/runtime-compatibility-scan.js.map +1 -0
  9. package/dist/commands/runtime-init.d.ts +6 -0
  10. package/dist/commands/runtime-init.d.ts.map +1 -1
  11. package/dist/commands/runtime-init.js +127 -2
  12. package/dist/commands/runtime-init.js.map +1 -1
  13. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  14. package/dist/commands/runtime-internalization-run-once.js +6 -1
  15. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  16. package/dist/index.js +4 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  19. package/dist/services/rulehost-pipeline-runner.js +6 -1
  20. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/commands/demo-story-a.ts +44 -0
  23. package/src/commands/runtime-compatibility-scan.ts +105 -0
  24. package/src/commands/runtime-init.ts +156 -2
  25. package/src/commands/runtime-internalization-run-once.ts +8 -1
  26. package/src/index.ts +5 -0
  27. package/src/services/rulehost-pipeline-runner.ts +6 -1
  28. package/tests/commands/demo-story-a.test.ts +63 -0
  29. package/tests/commands/runtime-compatibility-scan.test.ts +146 -0
  30. package/tests/commands/runtime-init-empty-workspace.test.ts +28 -0
  31. package/tests/commands/runtime-init.test.ts +71 -4
  32. package/tests/e2e/cross-package-acceptance.test.ts +2 -2
  33. package/tests/services/demo-rule-compiler.test.ts +2 -2
@@ -18,12 +18,38 @@
18
18
  */
19
19
 
20
20
  import * as path from 'path';
21
- import { SqliteConnection } from '@principles/core/runtime-v2';
22
- import { SchemaConformanceReadModel } from '@principles/core/runtime-v2';
21
+ import * as fs from 'fs';
22
+ import * as yaml from 'js-yaml';
23
+ import { SqliteConnection, SchemaConformanceReadModel } from '@principles/core/runtime-v2';
24
+ import { getDefaultPdConfig, validatePdConfig } from '@principles/core/runtime-v2';
23
25
  import { initTrajectorySchema, initWorkflowSchema } from 'principles-disciple';
24
26
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
25
27
  import { emitResult, emitFlagConflict, emitError } from '../services/cli-output.js';
26
28
 
29
+ // ── Constants ─────────────────────────────────────────────────────────────────
30
+
31
+ const CONFIG_DIR = '.pd';
32
+ const CONFIG_FILENAME = 'config.yaml';
33
+ const CONFIG_HEADER =
34
+ `# PD Runtime Configuration — single source of truth (.pd/config.yaml, ADR-0016)\n` +
35
+ `# Auto-generated by 'pd runtime init'. Existing config files are never overwritten.\n` +
36
+ `# Edit to configure feature flags, runtime profiles, internal agents, and UI.\n`;
37
+
38
+ /**
39
+ * Type guard for Node.js errno errors (rc-2: no `as` on caught unknown).
40
+ * Mirrors principle-tree-ledger.ts — uses Object.hasOwn, not `in`.
41
+ * Structural type (not NodeJS.ErrnoException) to avoid the NodeJS global,
42
+ * which is not defined in this package's eslint config.
43
+ * Needed to detect EEXIST from the atomic exclusive write below.
44
+ */
45
+ interface ErrnoExceptionLike {
46
+ code?: string;
47
+ }
48
+
49
+ function isErrnoException(value: unknown): value is ErrnoExceptionLike {
50
+ return typeof value === 'object' && value !== null && Object.hasOwn(value, 'code');
51
+ }
52
+
27
53
  // ── Output types ─────────────────────────────────────────────────────────────
28
54
 
29
55
  export interface DatabaseInitResult {
@@ -34,11 +60,18 @@ export interface DatabaseInitResult {
34
60
  warnings: string[];
35
61
  }
36
62
 
63
+ export interface ConfigInitResult {
64
+ path: string;
65
+ status: 'initialized' | 'skipped' | 'failed' | 'verified';
66
+ warnings: string[];
67
+ }
68
+
37
69
  export interface RuntimeInitOutput {
38
70
  ok: boolean;
39
71
  mode: 'dry-run' | 'confirm';
40
72
  workspace: string;
41
73
  databases: DatabaseInitResult[];
74
+ config?: ConfigInitResult;
42
75
  warnings: string[];
43
76
  reason?: string;
44
77
  nextAction?: string;
@@ -52,6 +85,100 @@ const DB_NAMES = {
52
85
  workflow: 'subagent_workflows.db',
53
86
  } as const;
54
87
 
88
+ // ── Config file generation ─────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * Build a clean YAML representation of the default PD config, injecting the
92
+ * workspace.default path so that discoverWorkspaceDefault() can find it.
93
+ *
94
+ * Returns the YAML string (with header comment) for writing to disk.
95
+ */
96
+ function buildConfigYaml(workspaceDir: string): string {
97
+ const config = getDefaultPdConfig();
98
+ const yamlObj: Record<string, unknown> = {
99
+ version: config.version,
100
+ workspace: {
101
+ default: workspaceDir,
102
+ },
103
+ features: config.features,
104
+ runtimeProfiles: config.runtimeProfiles,
105
+ internalAgents: config.internalAgents,
106
+ ui: config.ui,
107
+ };
108
+ return CONFIG_HEADER + yaml.dump(yamlObj, {
109
+ indent: 2,
110
+ lineWidth: 120,
111
+ noRefs: true,
112
+ sortKeys: false,
113
+ quotingType: '"',
114
+ forceQuotes: false,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Generate and write .pd/config.yaml if it does not already exist.
120
+ * Validates the generated file with a round-trip parse+validate check.
121
+ */
122
+ function buildConfigInitResult(workspaceDir: string, confirm: boolean): ConfigInitResult {
123
+ const configPath = path.join(workspaceDir, CONFIG_DIR, CONFIG_FILENAME);
124
+ const warnings: string[] = [];
125
+
126
+ if (!confirm) {
127
+ return { path: configPath, status: 'skipped', warnings: [] };
128
+ }
129
+
130
+ // Ensure .pd directory exists
131
+ const configDir = path.dirname(configPath);
132
+ try {
133
+ fs.mkdirSync(configDir, { recursive: true });
134
+ } catch (err) {
135
+ const reason = err instanceof Error ? err.message : String(err);
136
+ return { path: configPath, status: 'failed', warnings: [`Failed to create ${configDir}: ${reason}`] };
137
+ }
138
+
139
+ // Build and write YAML
140
+ let yamlContent: string;
141
+ try {
142
+ yamlContent = buildConfigYaml(workspaceDir);
143
+ } catch (err) {
144
+ const reason = err instanceof Error ? err.message : String(err);
145
+ return { path: configPath, status: 'failed', warnings: [`Failed to build config YAML: ${reason}`] };
146
+ }
147
+
148
+ try {
149
+ // Atomic exclusive create ('wx'): the write itself is the authoritative
150
+ // existence check — fails with EEXIST if the file already exists (from a
151
+ // previous run or a concurrent process). No existsSync pre-check, so there
152
+ // is no TOCTOU window and the "never overwrite existing config" contract
153
+ // is race-free (CodeQL js/race-condition).
154
+ fs.writeFileSync(configPath, yamlContent, { encoding: 'utf8', flag: 'wx' });
155
+ } catch (err) {
156
+ if (isErrnoException(err) && err.code === 'EEXIST') {
157
+ return { path: configPath, status: 'skipped', warnings: ['config.yaml already exists, skipped'] };
158
+ }
159
+ const reason = err instanceof Error ? err.message : String(err);
160
+ return { path: configPath, status: 'failed', warnings: [`Failed to write ${configPath}: ${reason}`] };
161
+ }
162
+
163
+ // Round-trip validation: read back, parse, validate
164
+ try {
165
+ const raw = fs.readFileSync(configPath, 'utf8');
166
+ const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
167
+ const validationResult = validatePdConfig(parsed);
168
+ if (!validationResult.ok) {
169
+ warnings.push(`config.yaml round-trip validation produced ${validationResult.errors.length} warnings`);
170
+ for (const ve of validationResult.errors) {
171
+ warnings.push(` [${ve.path}] ${ve.reason} — ${ve.nextAction}`);
172
+ }
173
+ }
174
+ } catch (err) {
175
+ // Non-fatal: proceed even if round-trip check fails
176
+ warnings.push(`config.yaml round-trip check skipped: ${err instanceof Error ? err.message : String(err)}`);
177
+ }
178
+
179
+ return { path: configPath, status: 'initialized', warnings };
180
+ }
181
+
55
182
  export function buildRuntimeInitOutput(workspaceDir: string, confirm: boolean): RuntimeInitOutput {
56
183
  const resolvedWorkspace = path.resolve(workspaceDir);
57
184
  const warnings: string[] = [];
@@ -213,11 +340,27 @@ export function buildRuntimeInitOutput(workspaceDir: string, confirm: boolean):
213
340
  }
214
341
  }
215
342
 
343
+ // 4. config.yaml — scaffold the single source of truth (P0: runtime discovery)
344
+ const config = buildConfigInitResult(resolvedWorkspace, confirm);
345
+ if (config.status === 'failed') {
346
+ return {
347
+ ok: false,
348
+ mode: 'confirm',
349
+ workspace: resolvedWorkspace,
350
+ databases,
351
+ config,
352
+ warnings,
353
+ reason: config.warnings[0] ?? 'config.yaml initialization failed',
354
+ nextAction: 'Check .pd directory permissions and disk space, then re-run `pd runtime init --confirm`.',
355
+ };
356
+ }
357
+
216
358
  return {
217
359
  ok: true,
218
360
  mode: confirm ? 'confirm' : 'dry-run',
219
361
  workspace: resolvedWorkspace,
220
362
  databases,
363
+ config,
221
364
  warnings,
222
365
  };
223
366
  }
@@ -246,6 +389,17 @@ function formatTextOutput(output: RuntimeInitOutput): string {
246
389
  }
247
390
  }
248
391
 
392
+ if (output.config) {
393
+ const icon = output.config.status === 'initialized' ? '[+]' :
394
+ output.config.status === 'verified' ? '[v]' :
395
+ output.config.status === 'skipped' ? '[ ]' : '[x]';
396
+ lines.push(`${icon} config.yaml (${output.config.status})`);
397
+ lines.push(` path: ${output.config.path}`);
398
+ for (const w of output.config.warnings) {
399
+ lines.push(` warning: ${w}`);
400
+ }
401
+ }
402
+
249
403
  if (output.warnings.length > 0) {
250
404
  lines.push('');
251
405
  lines.push('Warnings:');
@@ -20,6 +20,8 @@ import {
20
20
  import type { WakeOnceResult, DreamerRunnerResult, PhilosopherRunnerResult, ScribeRunnerResult, ArtificerRunnerResult, EvaluatorRunnerResult, RolloutReviewerRunnerResult, PDRuntimeAdapter, PeerRunnerKind, OutputLanguage } from '@principles/core/runtime-v2';
21
21
  import { resolveWorkspaceDir } from '../resolve-workspace.js';
22
22
  import { readOutputLanguageFromWorkspace } from '../config-reader.js';
23
+ import { loadPdConfig } from '../services/pd-config-loader.js';
24
+ import type { EffectivePdConfig } from '@principles/core/runtime-v2';
23
25
  import {
24
26
  resolveRuntimeAdapterFromConfig,
25
27
  ConfigResolutionError,
@@ -447,6 +449,11 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
447
449
  const stateManager = new RuntimeStateManager({ workspaceDir });
448
450
  await stateManager.initialize();
449
451
 
452
+ // Issue 2: resolve effective config for feature-flag-aware runners
453
+ // (e.g. `artificer_output_retry`). Mirrors rulehost-pipeline-runner.
454
+ const configLoad = loadPdConfig(workspaceDir);
455
+ const effectiveConfig: EffectivePdConfig | undefined = configLoad.ok ? configLoad.effective : configLoad.defaults;
456
+
450
457
  try {
451
458
  const orchestrator = new InternalizationOrchestrator(
452
459
  { stateManager },
@@ -515,7 +522,7 @@ export async function handleRuntimeInternalizationRunOnce(opts: RunOnceOptions):
515
522
  const validator = new DefaultArtificerValidator();
516
523
  const runner = new ArtificerRunner(
517
524
  { stateManager, runtimeAdapter, eventEmitter, validator, artifactStore, contentHashFn },
518
- { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs },
525
+ { owner: OWNER, runtimeKind: runtimeAdapter.kind(), pollIntervalMs: 100, timeoutMs: effectiveTimeoutMs, effectiveConfig },
519
526
  );
520
527
  runnerResult = await runner.run(wakeResult.taskId);
521
528
  } else if (runnerKind === 'evaluator') {
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ import { handleRuntimeInternalizationIntegrityRepair } from './commands/runtime-
45
45
  import { handleRuntimeInternalizationEnqueueSuccessors } from './commands/runtime-internalization-enqueue-successors.js';
46
46
  import { handleRuntimeInternalizationContextTrace } from './commands/runtime-internalization-context-trace.js';
47
47
  import { handleRuntimeDiagnosticsExport } from './commands/runtime-diagnostics-export.js';
48
+ import { registerRuntimeCompatibilityScanCommand } from './commands/runtime-compatibility-scan.js';
48
49
  import { handleRuntimeRecoverySweep } from './commands/runtime-recovery.js';
49
50
  import { handleRuntimeRecoveryFailedTasks } from './commands/runtime-recovery-failed-tasks.js';
50
51
  import {
@@ -341,6 +342,8 @@ runtimeCmd
341
342
  });
342
343
  });
343
344
 
345
+ registerRuntimeCompatibilityScanCommand(runtimeCmd);
346
+
344
347
  const synthCmd = runtimeCmd
345
348
  .command('synthetic', { hidden: true })
346
349
  .description('Synthetic workload baseline commands');
@@ -455,11 +458,13 @@ demoCmd
455
458
  .option('-w, --workspace <path>', 'Workspace directory (default: temp workspace)')
456
459
  .option('--json', 'Output raw JSON')
457
460
  .option('--channels <channels>', 'Comma-separated channel list (prompt,code_tool_hook,defer_archive)')
461
+ .option('--allow-demo-write-to-existing-workspace', 'Developer override: permit writing demo artifacts into a workspace that already contains PD state (demo isolation guard)')
458
462
  .action(async (opts) => {
459
463
  await handleDemoStoryA({
460
464
  workspace: opts.workspace,
461
465
  json: opts.json,
462
466
  channels: opts.channels,
467
+ allowDemoWriteToExistingWorkspace: opts.allowDemoWriteToExistingWorkspace === true,
463
468
  });
464
469
  });
465
470
 
@@ -272,6 +272,11 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
272
272
  try {
273
273
  const artifactStore = stateManager.piArtifactStore;
274
274
  const eventEmitter = new StoreEventEmitter();
275
+ // Issue 2: load effective config once so every stage runner can resolve
276
+ // feature flags (e.g. `artificer_output_retry`). Mirrors
277
+ // createEvaluatorRunnerDeps (rc-9: malformed config → fallback defaults).
278
+ const configLoad = loadPdConfig(opts.workspaceDir);
279
+ const effectiveConfig = configLoad.ok ? configLoad.effective : configLoad.defaults;
275
280
  // Allow the caller's adapter to resolve real artifactIds (needed by
276
281
  // test-double adapters whose scripted outputs must match store-assigned IDs).
277
282
  opts.onStoreReady?.(artifactStore);
@@ -282,7 +287,7 @@ export async function runRuleHostPipeline(opts: RuleHostPipelineOptions): Promis
282
287
  scribe: opts.runtimeAdapter,
283
288
  evaluator: opts.runtimeAdapter,
284
289
  };
285
- const runnerOptsFor = (adapter: PDRuntimeAdapter) => ({ owner, runtimeKind: adapter.kind(), pollIntervalMs, timeoutMs });
290
+ const runnerOptsFor = (adapter: PDRuntimeAdapter) => ({ owner, runtimeKind: adapter.kind(), pollIntervalMs, timeoutMs, effectiveConfig });
286
291
 
287
292
  // ── Stage: pain lookup ──
288
293
  // Find a dreamer task already seeded for this pain (the pain→dreamer bridge
@@ -238,6 +238,69 @@ describe('pd demo story-a CLI', () => {
238
238
  expect(parsed.narrative).toContain('[SIMULATED]');
239
239
  expect(parsed.narrative).toContain('[REAL]');
240
240
  });
241
+ // ── Demo isolation (2026-08-19): demo must not pollute real PD workspaces ──
242
+
243
+ it('refuses to write into a workspace that already contains PD state (default)', async () => {
244
+ const existing = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-demo-isolation-'));
245
+ try {
246
+ fs.mkdirSync(path.join(existing, '.pd'), { recursive: true });
247
+ fs.writeFileSync(path.join(existing, '.pd', 'state.db'), '');
248
+
249
+ await handleDemoStoryA({ workspace: existing, json: true });
250
+
251
+ expect(process.exitCode).toBe(1);
252
+ const output = stdoutSpy.mock.calls.map(c => c[0]).join('');
253
+ const parsed = JSON.parse(output);
254
+ expect(parsed.status).toBe('refused');
255
+ expect(parsed.refusal.reason).toBe('demo_write_to_existing_workspace');
256
+ expect(parsed.refusal.nextAction).toContain('--allow-demo-write-to-existing-workspace');
257
+ // cli-5: no mutation on the refused path — the marker file is untouched.
258
+ const stat = fs.statSync(path.join(existing, '.pd', 'state.db'));
259
+ expect(stat.size).toBe(0);
260
+ } finally {
261
+ fs.rmSync(existing, { recursive: true, force: true });
262
+ }
263
+ });
264
+
265
+ it('text mode refusal points at the override flag', async () => {
266
+ const existing = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-demo-isolation-'));
267
+ try {
268
+ fs.mkdirSync(path.join(existing, '.pd'), { recursive: true });
269
+ fs.writeFileSync(path.join(existing, '.pd', 'state.db'), '');
270
+
271
+ await handleDemoStoryA({ workspace: existing });
272
+
273
+ expect(process.exitCode).toBe(1);
274
+ const output = stderrSpy.mock.calls.map(c => c[0]).join('');
275
+ expect(output).toContain('existing PD workspace');
276
+ expect(output).toContain('--allow-demo-write-to-existing-workspace');
277
+ } finally {
278
+ fs.rmSync(existing, { recursive: true, force: true });
279
+ }
280
+ });
281
+
282
+ it('developer override allows writing into the existing workspace with origin:demo provenance', async () => {
283
+ const existing = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-demo-override-'));
284
+ try {
285
+ fs.mkdirSync(path.join(existing, '.pd'), { recursive: true });
286
+ fs.writeFileSync(path.join(existing, '.pd', 'config.yaml'), 'features: {}');
287
+
288
+ await handleDemoStoryA({ workspace: existing, json: true, allowDemoWriteToExistingWorkspace: true });
289
+
290
+ const output = stdoutSpy.mock.calls.map(c => c[0]).join('');
291
+ const parsed = JSON.parse(output);
292
+ expect(parsed.status).not.toBe('refused');
293
+ const db = new Database(path.join(existing, '.pd', 'state.db'), { readonly: true });
294
+ const rows = db.prepare('SELECT content_json FROM pi_artifacts').all() as { content_json: string }[];
295
+ db.close();
296
+ expect(rows.length).toBeGreaterThan(0);
297
+ for (const row of rows) {
298
+ expect(JSON.parse(row.content_json).origin).toBe('demo');
299
+ }
300
+ } finally {
301
+ fs.rmSync(existing, { recursive: true, force: true });
302
+ }
303
+ });
241
304
  });
242
305
 
243
306
  describe('cleanupTempWorkspace', () => {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * runtime compatibility-scan tests — pd runtime compatibility-scan (P1-3).
3
+ *
4
+ * Real persisted-workspace fixtures (SqliteConnection + activation store) —
5
+ * no DB mocks, the production read path is exercised end to end (EP-09).
6
+ *
7
+ * Covers:
8
+ * - SCAN-01: clean RuleContextV2-only active rule → exit 0, status clean (cli-1/cli-6)
9
+ * - SCAN-02: active rule reading session.recentThinking → exit 1,
10
+ * reason legacy_rule_contract_dependency, remediation names the rule (cli-6)
11
+ * - SCAN-03: workspace without state.db → exit 0, status no_state_db
12
+ * - SCAN-04: --json emits exactly one parseable JSON object (cli-1)
13
+ * - SCAN-05: command wiring — real Commander registration (cli-7)
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
17
+ import * as path from 'node:path';
18
+ import * as os from 'node:os';
19
+ import * as fs from 'node:fs';
20
+ import { SqliteConnection, SqliteActivationStateStore } from '@principles/core/runtime-v2';
21
+ import { handleRuntimeCompatibilityScan } from '../../src/commands/runtime-compatibility-scan.js';
22
+
23
+ const LEGACY_CODE = `
24
+ function evaluate(input, helpers) {
25
+ if (input.session && input.session.recentThinking === true) {
26
+ return { decision: 'block', matched: true };
27
+ }
28
+ return { decision: 'allow', matched: false };
29
+ }
30
+ `;
31
+
32
+ const CLEAN_CODE = `
33
+ function evaluate(input, helpers) {
34
+ var h = input.context && input.context.history;
35
+ return { decision: 'allow', matched: false };
36
+ }
37
+ `;
38
+
39
+ let tempWorkspaceDir: string;
40
+ let conn: SqliteConnection;
41
+
42
+ beforeEach(() => {
43
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
44
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
45
+ process.exitCode = undefined;
46
+ tempWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-compat-cmd-'));
47
+ conn = new SqliteConnection(tempWorkspaceDir);
48
+ conn.getDb();
49
+ });
50
+
51
+ afterEach(() => {
52
+ vi.restoreAllMocks();
53
+ try { conn?.close(); } catch { /* best-effort */ }
54
+ try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
55
+ });
56
+
57
+ async function seedActiveRule(artifactId: string, ruleId: string, implementationCode: string): Promise<void> {
58
+ const now = new Date().toISOString();
59
+ conn.getDb().prepare(`
60
+ INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
61
+ VALUES (?, 'rule', ?, ?, ?, '[]', 'validated', ?, ?, ?)
62
+ `).run(artifactId, `task-${artifactId}`, `principle-${ruleId}`, ruleId, JSON.stringify({ ruleId, implementationCode }), now, now);
63
+ await new SqliteActivationStateStore(conn).recordActivation({
64
+ activationId: `act-${artifactId}`,
65
+ idempotencyKey: `${artifactId}::code_tool_hook`,
66
+ artifactId,
67
+ channel: 'code_tool_hook',
68
+ action: 'code_tool_hook_live_activate',
69
+ targetRef: `impl://${ruleId}`,
70
+ activatedAt: now,
71
+ deactivatedAt: null,
72
+ });
73
+ }
74
+
75
+ function capturedStdout(): string {
76
+ const calls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls;
77
+ return calls.map(c => String(c[0] ?? '')).join('\n');
78
+ }
79
+
80
+ describe('pd runtime compatibility-scan', () => {
81
+ it('SCAN-01: clean current-contract rule exits 0 with status clean', async () => {
82
+ await seedActiveRule('art-clean', 'rule-clean', CLEAN_CODE);
83
+ await handleRuntimeCompatibilityScan({ workspace: tempWorkspaceDir, json: true });
84
+ expect(process.exitCode).toBeUndefined();
85
+ const parsed = JSON.parse(capturedStdout()) as Record<string, unknown>;
86
+ expect(parsed['status']).toBe('clean');
87
+ expect(parsed['ok']).toBe(true);
88
+ expect(parsed['findings']).toEqual([]);
89
+ });
90
+
91
+ it('SCAN-02: legacy recentThinking rule exits 1 with structured reason + remediation', async () => {
92
+ await seedActiveRule('art-legacy', 'rule-real-diagnosis-first', LEGACY_CODE);
93
+ await handleRuntimeCompatibilityScan({ workspace: tempWorkspaceDir, json: true });
94
+ expect(process.exitCode).toBe(1);
95
+ const parsed = JSON.parse(capturedStdout()) as Record<string, unknown>;
96
+ expect(parsed['ok']).toBe(false);
97
+ expect(parsed['status']).toBe('legacy_dependency');
98
+ expect(parsed['reason']).toBe('legacy_rule_contract_dependency');
99
+ const findings = parsed['findings'] as Array<Record<string, unknown>>;
100
+ expect(findings).toHaveLength(1);
101
+ expect(findings[0]).toMatchObject({ symbol: 'recentThinking', ruleId: 'rule-real-diagnosis-first' });
102
+ const remediation = parsed['remediation'] as string;
103
+ expect(remediation).toContain('rule-real-diagnosis-first');
104
+ expect(remediation).toContain('igrate or deactivate');
105
+ });
106
+
107
+ it('SCAN-03: workspace without state.db exits 0 with status no_state_db', async () => {
108
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-compat-empty-cmd-'));
109
+ try {
110
+ await handleRuntimeCompatibilityScan({ workspace: emptyDir, json: true });
111
+ expect(process.exitCode).toBeUndefined();
112
+ const parsed = JSON.parse(capturedStdout()) as Record<string, unknown>;
113
+ expect(parsed['status']).toBe('no_state_db');
114
+ expect(parsed['ok']).toBe(true);
115
+ // Side-effect-free: the scan must not create a state.db (cli-5).
116
+ expect(fs.existsSync(path.join(emptyDir, '.pd', 'state.db'))).toBe(false);
117
+ } finally {
118
+ fs.rmSync(emptyDir, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ it('SCAN-04: --json stdout is exactly one parseable JSON object (cli-1)', async () => {
123
+ await seedActiveRule('art-clean2', 'rule-clean2', CLEAN_CODE);
124
+ await handleRuntimeCompatibilityScan({ workspace: tempWorkspaceDir, json: true });
125
+ const out = capturedStdout().trim();
126
+ expect(out.startsWith('{')).toBe(true);
127
+ expect(out.endsWith('}')).toBe(true);
128
+ expect(() => JSON.parse(out)).not.toThrow();
129
+ expect((out.match(/\{/g) ?? []).length).toBeGreaterThanOrEqual(1);
130
+ });
131
+
132
+ it('SCAN-05: command is registered on a real Commander program (cli-7)', async () => {
133
+ const { Command } = await import('commander');
134
+ const { registerRuntimeCompatibilityScanCommand } = await import('../../src/commands/runtime-compatibility-scan.js');
135
+ const program = new Command();
136
+ program.name('pd').exitOverride();
137
+ const runtimeCmd = program.command('runtime').description('Runtime inspection and health checks');
138
+ registerRuntimeCompatibilityScanCommand(runtimeCmd);
139
+ const scanCmd = runtimeCmd.commands.find(c => c.name() === 'compatibility-scan');
140
+ expect(scanCmd).toBeDefined();
141
+ expect(scanCmd?.description()).toContain('retired RuleHost contract');
142
+ // Flag wiring: -w/--workspace and --json registered; no mutating flags exist.
143
+ expect(scanCmd?.options.map(o => o.long)).toContain('--workspace');
144
+ expect(scanCmd?.options.map(o => o.long)).toContain('--json');
145
+ });
146
+ });
@@ -18,7 +18,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
18
  import * as fs from 'node:fs';
19
19
  import * as path from 'node:path';
20
20
  import * as os from 'node:os';
21
+ import * as yaml from 'js-yaml';
21
22
  import Database from 'better-sqlite3';
23
+ import { validatePdConfig } from '@principles/core/runtime-v2';
22
24
  import { buildRuntimeInitOutput } from '../../src/commands/runtime-init.js';
23
25
 
24
26
  // ── Helpers ─────────────────────────────────────────────────────────────────
@@ -190,6 +192,26 @@ describe('pd runtime init — empty workspace integration', () => {
190
192
  db.close();
191
193
  }
192
194
  });
195
+
196
+ // ── P0: config.yaml scaffolding (Issue 1b) ────────────────────────────────
197
+
198
+ it('creates .pd/config.yaml with version 1 and workspace.default set', () => {
199
+ buildRuntimeInitOutput(tmpDir, true);
200
+ const configPath = path.join(tmpDir, '.pd', 'config.yaml');
201
+ expect(fs.existsSync(configPath)).toBe(true);
202
+ const parsed = yaml.load(fs.readFileSync(configPath, 'utf8'), { schema: yaml.JSON_SCHEMA });
203
+ const obj = parsed as { version?: unknown; workspace?: { default?: unknown } };
204
+ expect(obj.version).toBe(1);
205
+ expect(obj.workspace?.default).toBe(path.resolve(tmpDir));
206
+ });
207
+
208
+ it('generated config.yaml passes validatePdConfig (round-trip)', () => {
209
+ buildRuntimeInitOutput(tmpDir, true);
210
+ const configPath = path.join(tmpDir, '.pd', 'config.yaml');
211
+ const parsed: unknown = yaml.load(fs.readFileSync(configPath, 'utf8'), { schema: yaml.JSON_SCHEMA });
212
+ const result = validatePdConfig(parsed);
213
+ expect(result.ok).toBe(true);
214
+ });
193
215
  });
194
216
 
195
217
  // ── EMPTY-02: idempotency ──────────────────────────────────────────────────
@@ -215,6 +237,12 @@ describe('pd runtime init — empty workspace integration', () => {
215
237
  // Table set should be unchanged
216
238
  const tables2 = getTableNames(trajDbPath);
217
239
  expect(tables2).toEqual(tables1);
240
+
241
+ // config.yaml is preserved: second run reports skipped, content unchanged
242
+ expect(output2.config?.status).toBe('skipped');
243
+ const configPath = path.join(tmpDir, '.pd', 'config.yaml');
244
+ const config1 = fs.readFileSync(configPath, 'utf8');
245
+ expect(fs.readFileSync(configPath, 'utf8')).toBe(config1);
218
246
  });
219
247
 
220
248
  it('dry-run after confirm does not modify existing DBs', () => {
@@ -40,10 +40,14 @@ vi.mock('principles-disciple', () => ({
40
40
  initWorkflowSchema: mockState.initWorkflowSchema,
41
41
  }));
42
42
 
43
- vi.mock('@principles/core/runtime-v2', () => ({
44
- SqliteConnection: mockState.sqliteConnectionCtor,
45
- SchemaConformanceReadModel: mockState.schemaConformanceCtor,
46
- }));
43
+ vi.mock('@principles/core/runtime-v2', async () => {
44
+ const actual = await vi.importActual<typeof import('@principles/core/runtime-v2')>('@principles/core/runtime-v2');
45
+ return {
46
+ ...actual,
47
+ SqliteConnection: mockState.sqliteConnectionCtor,
48
+ SchemaConformanceReadModel: mockState.schemaConformanceCtor,
49
+ };
50
+ });
47
51
 
48
52
  vi.mock('../../src/resolve-workspace.js', () => ({
49
53
  resolveWorkspaceDir: mockState.resolveWorkspaceDir,
@@ -343,4 +347,67 @@ describe('pd runtime init', () => {
343
347
  } finally { rmTmpDir(tmp); }
344
348
  });
345
349
  });
350
+
351
+ // ── INIT-07: config.yaml scaffolding (P0: runtime discovery) ───────────────
352
+
353
+ describe('INIT-07: config.yaml scaffolding', () => {
354
+ it('dry-run reports config.yaml as skipped without writing the file', () => {
355
+ const tmp = mkTmpDir();
356
+ try {
357
+ const output = buildRuntimeInitOutput(tmp, false);
358
+ expect(output.config).toBeDefined();
359
+ expect(output.config?.status).toBe('skipped');
360
+ expect(fs.existsSync(path.join(tmp, '.pd', 'config.yaml'))).toBe(false);
361
+ } finally { rmTmpDir(tmp); }
362
+ });
363
+
364
+ it('--confirm writes a valid config.yaml with version and workspace.default', async () => {
365
+ const tmp = mkTmpDir();
366
+ try {
367
+ const output = buildRuntimeInitOutput(tmp, true);
368
+ expect(output.config?.status).toBe('initialized');
369
+ const configPath = path.join(tmp, '.pd', 'config.yaml');
370
+ expect(fs.existsSync(configPath)).toBe(true);
371
+ const yaml = (await import('js-yaml')).default;
372
+ const raw = fs.readFileSync(configPath, 'utf8');
373
+ const parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
374
+ expect(typeof parsed).toBe('object');
375
+ const obj = parsed as { version?: unknown; workspace?: { default?: unknown }; features?: unknown };
376
+ expect(obj.version).toBe(1);
377
+ expect(obj.workspace?.default).toBe(path.resolve(tmp));
378
+ expect(obj.features).toBeDefined();
379
+ expect(Object.keys(obj.features as Record<string, unknown>).length).toBeGreaterThan(0);
380
+ } finally { rmTmpDir(tmp); }
381
+ });
382
+
383
+ it('round-trip: generated config passes validatePdConfig', async () => {
384
+ const tmp = mkTmpDir();
385
+ try {
386
+ buildRuntimeInitOutput(tmp, true);
387
+ const configPath = path.join(tmp, '.pd', 'config.yaml');
388
+ const yaml = (await import('js-yaml')).default;
389
+ const { validatePdConfig } = await import('@principles/core/runtime-v2');
390
+ const parsed: unknown = yaml.load(fs.readFileSync(configPath, 'utf8'), { schema: yaml.JSON_SCHEMA });
391
+ const result = validatePdConfig(parsed);
392
+ expect(result.ok).toBe(true);
393
+ } finally { rmTmpDir(tmp); }
394
+ });
395
+
396
+ it('--confirm skips when config.yaml already exists (preserves user file)', () => {
397
+ const tmp = mkTmpDir();
398
+ try {
399
+ // Pre-create a user-modified config.yaml
400
+ const configDir = path.join(tmp, '.pd');
401
+ fs.mkdirSync(configDir, { recursive: true });
402
+ const userConfig = '# user config\nversion: 1\n';
403
+ fs.writeFileSync(path.join(configDir, 'config.yaml'), userConfig, 'utf8');
404
+
405
+ const output = buildRuntimeInitOutput(tmp, true);
406
+ expect(output.config?.status).toBe('skipped');
407
+ expect(output.config?.warnings.some(w => w.includes('already exists'))).toBe(true);
408
+ // Content must be preserved
409
+ expect(fs.readFileSync(path.join(configDir, 'config.yaml'), 'utf8')).toBe(userConfig);
410
+ } finally { rmTmpDir(tmp); }
411
+ });
412
+ });
346
413
  });
@@ -416,8 +416,8 @@ describe('Cross-Package Acceptance Test (PRI-408 P1/P2 fixes) — unsplippable c
416
416
  // undefined (no block) for shadow activations, even for /etc/passwd.
417
417
  const makeRuleHostInput = (targetPath: string): RuleHostInput => ({
418
418
  action: { toolName: 'write_file', normalizedPath: targetPath, paramsSummary: { path: targetPath } },
419
- workspace: { isRiskPath: targetPath.startsWith('/etc'), planStatus: 'NONE', hasPlanFile: false },
420
- session: { currentGfi: 0, recentThinking: false },
419
+ workspace: { isRiskPath: targetPath.startsWith('/etc') },
420
+ session: { currentGfi: 0 },
421
421
  evolution: { epTier: 0 },
422
422
  derived: { estimatedLineChanges: 1, bashRisk: 'safe' },
423
423
  });
@@ -108,8 +108,8 @@ function makeRuleHostInput(estimatedLineChanges = 0): RuleHostInput {
108
108
  normalizedPath: '/workspace/a.ts',
109
109
  paramsSummary: {},
110
110
  },
111
- workspace: { isRiskPath: false, planStatus: 'READY', hasPlanFile: true },
112
- session: { currentGfi: 0, recentThinking: true },
111
+ workspace: { isRiskPath: false },
112
+ session: { currentGfi: 0 },
113
113
  evolution: { epTier: 0 },
114
114
  derived: { estimatedLineChanges, bashRisk: 'safe' },
115
115
  };