@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.
- package/dist/commands/demo-story-a.d.ts +2 -0
- package/dist/commands/demo-story-a.d.ts.map +1 -1
- package/dist/commands/demo-story-a.js +41 -0
- package/dist/commands/demo-story-a.js.map +1 -1
- package/dist/commands/runtime-compatibility-scan.d.ts +32 -0
- package/dist/commands/runtime-compatibility-scan.d.ts.map +1 -0
- package/dist/commands/runtime-compatibility-scan.js +94 -0
- package/dist/commands/runtime-compatibility-scan.js.map +1 -0
- package/dist/commands/runtime-init.d.ts +6 -0
- package/dist/commands/runtime-init.d.ts.map +1 -1
- package/dist/commands/runtime-init.js +127 -2
- package/dist/commands/runtime-init.js.map +1 -1
- package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-run-once.js +6 -1
- package/dist/commands/runtime-internalization-run-once.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
- package/dist/services/rulehost-pipeline-runner.js +6 -1
- package/dist/services/rulehost-pipeline-runner.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/demo-story-a.ts +44 -0
- package/src/commands/runtime-compatibility-scan.ts +105 -0
- package/src/commands/runtime-init.ts +156 -2
- package/src/commands/runtime-internalization-run-once.ts +8 -1
- package/src/index.ts +5 -0
- package/src/services/rulehost-pipeline-runner.ts +6 -1
- package/tests/commands/demo-story-a.test.ts +63 -0
- package/tests/commands/runtime-compatibility-scan.test.ts +146 -0
- package/tests/commands/runtime-init-empty-workspace.test.ts +28 -0
- package/tests/commands/runtime-init.test.ts +71 -4
- package/tests/e2e/cross-package-acceptance.test.ts +2 -2
- 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
|
|
22
|
-
import
|
|
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
|
-
|
|
45
|
-
|
|
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')
|
|
420
|
-
session: { currentGfi: 0
|
|
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
|
|
112
|
-
session: { currentGfi: 0
|
|
111
|
+
workspace: { isRiskPath: false },
|
|
112
|
+
session: { currentGfi: 0 },
|
|
113
113
|
evolution: { epTier: 0 },
|
|
114
114
|
derived: { estimatedLineChanges, bashRisk: 'safe' },
|
|
115
115
|
};
|