@principles/pd-cli 1.147.0 → 1.147.2

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 (32) hide show
  1. package/dist/commands/rulecode.d.ts +11 -0
  2. package/dist/commands/rulecode.d.ts.map +1 -1
  3. package/dist/commands/rulecode.js +4 -1
  4. package/dist/commands/rulecode.js.map +1 -1
  5. package/dist/commands/runtime-activation.d.ts.map +1 -1
  6. package/dist/commands/runtime-activation.js +83 -3
  7. package/dist/commands/runtime-activation.js.map +1 -1
  8. package/dist/commands/runtime-internalization-run-once.d.ts.map +1 -1
  9. package/dist/commands/runtime-internalization-run-once.js +41 -1
  10. package/dist/commands/runtime-internalization-run-once.js.map +1 -1
  11. package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
  12. package/dist/services/rulehost-pipeline-runner.js +22 -1
  13. package/dist/services/rulehost-pipeline-runner.js.map +1 -1
  14. package/dist/services/workspace-tool-semantics.d.ts +19 -0
  15. package/dist/services/workspace-tool-semantics.d.ts.map +1 -0
  16. package/dist/services/workspace-tool-semantics.js +20 -0
  17. package/dist/services/workspace-tool-semantics.js.map +1 -0
  18. package/package.json +1 -1
  19. package/scripts/llm-dogfood.ts +24 -1
  20. package/src/commands/rulecode.ts +9 -1
  21. package/src/commands/runtime-activation.ts +83 -3
  22. package/src/commands/runtime-internalization-run-once.ts +42 -1
  23. package/src/services/rulehost-pipeline-runner.ts +21 -1
  24. package/src/services/workspace-tool-semantics.ts +26 -0
  25. package/tests/commands/health.test.ts +5 -0
  26. package/tests/commands/rulecode.test.ts +42 -0
  27. package/tests/commands/runtime-activation.test.ts +83 -1
  28. package/tests/commands/runtime-internalization-run-once-evaluator-parity.test.ts +269 -0
  29. package/tests/commands/runtime-internalization-run-once.test.ts +37 -0
  30. package/tests/e2e/cross-package-acceptance.test.ts +12 -0
  31. package/tests/services/rulehost-pipeline-runner.test.ts +14 -0
  32. package/tests/services/workspace-tool-semantics.test.ts +76 -0
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Workspace Tool Semantics Resolution — PRI-634-F R2 (review P1-2), R3
3
+ * (SPEC §五 P1-1): thin delegation to the ONE host-runtime resolver so CLI,
4
+ * Console, and any future entry point share the same multi-host
5
+ * load/merge/validate/resolve path. The CLI refusal policy (code_tool_hook
6
+ * refuses when unresolvable — no host guessing, no silent skip) stays here.
7
+ */
8
+
9
+ import type { ToolSemanticRegistry } from '@principles/core/runtime-v2';
10
+ import { resolveWorkspaceHostToolSemantics } from '@principles/host-runtime';
11
+
12
+ export type WorkspaceToolSemanticsResolution =
13
+ | { readonly ok: true; readonly registry: ToolSemanticRegistry; readonly hostKind: string }
14
+ | { readonly ok: false; readonly reason: string; readonly nextAction: string };
15
+
16
+ export function resolveWorkspaceToolSemantics(workspaceDir: string): WorkspaceToolSemanticsResolution {
17
+ const resolved = resolveWorkspaceHostToolSemantics(workspaceDir);
18
+ if (!resolved.ok) {
19
+ return { ok: false, reason: resolved.reason, nextAction: resolved.nextAction };
20
+ }
21
+ return {
22
+ ok: true,
23
+ registry: resolved.registry,
24
+ hostKind: resolved.hostKinds.join('+'),
25
+ };
26
+ }
@@ -31,6 +31,11 @@ const {
31
31
  });
32
32
 
33
33
  vi.mock('@principles/core/runtime-v2', () => ({
34
+ // PRI-634-F R2: full-module mocks must track new public exports — the
35
+ // activation graph imports buildToolSemanticRegistry transitively
36
+ // (workspace-tool-semantics), and vitest throws on the missing property
37
+ // access at import time even when never called.
38
+ buildToolSemanticRegistry: vi.fn().mockReturnValue({ ok: true, registry: {} }),
34
39
  PruningReadModel: vi.fn().mockImplementation(function () {
35
40
  return { getHealthSummary: mockPruningGetHealthSummary };
36
41
  }),
@@ -14,6 +14,7 @@ import * as fs from 'fs';
14
14
  import * as path from 'path';
15
15
  import os from 'os';
16
16
  import { loadGoldenTraceCases } from '../../src/commands/rulecode.js';
17
+ import { vi } from 'vitest';
17
18
 
18
19
  const VALID_CASES = JSON.stringify([
19
20
  {
@@ -133,3 +134,44 @@ describe('loadGoldenTraceCases containment', () => {
133
134
  expect(result.error?.reason).toContain('not valid JSON');
134
135
  });
135
136
  });
137
+
138
+
139
+ // ── PRI-634-F R2 (review P2): replay --json carries structured failure ──────
140
+
141
+ describe('handleRulecodeReplay — structured failure attribution on --json', () => {
142
+ it('a failing replay surfaces {layer, reasonCode, evidence, nextAction} in the JSON output', async () => {
143
+ const { handleRulecodeReplay } = await import('../../src/commands/rulecode.js');
144
+ const code = [
145
+ 'const evaluate = (input) => {',
146
+ ' return { decision: "allow", matched: false, reason: "never blocks" };',
147
+ '};',
148
+ ].join(String.fromCharCode(10));
149
+ // The --golden-trace CLI input is a JSON ARRAY of cases (not the
150
+ // persisted GoldenTrace envelope) — see loadGoldenTraceCases.
151
+ const cases = [
152
+ { caseId: 'negative-1', kind: 'negative', toolName: 'write_file', params: { file_path: '/prod.env' }, expectedDecision: 'block' },
153
+ { caseId: 'positive-1', kind: 'positive', toolName: 'write_file', params: { file_path: '/repo/a.ts' }, expectedDecision: 'allow' },
154
+ ];
155
+ const traceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-replay-'));
156
+ const traceFile = path.join(traceDir, 'golden-trace-cases.json');
157
+ fs.writeFileSync(traceFile, JSON.stringify(cases), 'utf8');
158
+
159
+ const logs: string[] = [];
160
+ const spy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { logs.push(String(args[0])); });
161
+ try {
162
+ await handleRulecodeReplay({ code, goldenTrace: traceFile, json: true });
163
+ } finally {
164
+ spy.mockRestore();
165
+ fs.rmSync(traceDir, { recursive: true, force: true });
166
+ }
167
+ expect(process.exitCode).toBe(1);
168
+ process.exitCode = 0;
169
+ const output = JSON.parse(logs[0]);
170
+ expect(output.status).toBe('failed');
171
+ expect(output.decision).toBe('rejected_validation_failed');
172
+ expect(output.failure).toBeDefined();
173
+ expect(output.failure.layer).toBe('rule');
174
+ expect(output.failure.reasonCode).toBe('replay_decision_mismatch');
175
+ expect(typeof output.failure.nextAction).toBe('string');
176
+ });
177
+ });
@@ -1,6 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { resolveWorkspaceToolSemantics } from '../../src/services/workspace-tool-semantics.js';
5
+ import { vi as _vi } from 'vitest';
4
6
  import { tmpdir } from 'node:os';
5
7
  import path from 'node:path';
6
8
 
@@ -25,6 +27,14 @@ const mockListCodeToolHookActivations = vi.fn().mockResolvedValue([
25
27
  { activationId: 'act-hook-1', artifactId: 'art-002', channel: 'code_tool_hook', action: 'code_tool_hook_shadow_activate', targetRef: 'rule-001', activatedAt: '2026-06-18T00:00:00.000Z', promotedAt: null, deactivatedAt: null },
26
28
  ]);
27
29
 
30
+ vi.mock('../../src/services/workspace-tool-semantics.js', () => ({
31
+ // Unit boundary: dispatch/approve tests exercise flag wiring and approval
32
+ // state transitions; host-provenance resolution has its own contract tests
33
+ // (workspace-tool-semantics.test.ts). Tests that need the refusal path
34
+ // override the return value via vi.mocked.
35
+ resolveWorkspaceToolSemantics: vi.fn().mockReturnValue({ ok: true, registry: { version: 1, hasHostLayer: true }, hostKind: 'test-host' }),
36
+ }));
37
+
28
38
  vi.mock('../../src/resolve-workspace.js', () => ({
29
39
  resolveWorkspaceDir: vi.fn().mockReturnValue('/fake/workspace'),
30
40
  }));
@@ -97,7 +107,9 @@ vi.mock('@principles/core/runtime-v2', async (importOriginal) => {
97
107
  };
98
108
  }),
99
109
  ApprovalQueue: vi.fn().mockImplementation(function () {
100
- return { approve: mockApprovalApprove, resetToPending: mockApprovalResetToPending };
110
+ // PRI-634-F R2: getById backs the pre-approve host-provenance check
111
+ // (null = no record; the approve path handles not-found itself).
112
+ return { approve: mockApprovalApprove, resetToPending: mockApprovalResetToPending, getById: async () => null };
101
113
  }),
102
114
  ApprovalCompletionService: vi.fn().mockImplementation(function () {
103
115
  return { completeApproval: mockCompletionComplete };
@@ -1966,3 +1978,73 @@ describe('PRI-499: cli-2-exit-stops — failure paths set exitCode and do not co
1966
1978
  expect(RuntimeStateManager).not.toHaveBeenCalled();
1967
1979
  });
1968
1980
  });
1981
+
1982
+
1983
+ describe('handleRuntimeActivationDispatch — PRI-634-F R2 host provenance refusal', () => {
1984
+ let consoleLogSpy: ReturnType<typeof vi.spyOn>;
1985
+
1986
+ beforeEach(() => {
1987
+ vi.clearAllMocks();
1988
+ mockRuleHostWriterConfigs.length = 0;
1989
+ mockGetArtifactById.mockResolvedValue(makeArtifact());
1990
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1991
+ });
1992
+
1993
+ afterEach(() => {
1994
+ consoleLogSpy.mockRestore();
1995
+ process.exitCode = 0;
1996
+ });
1997
+
1998
+ it('refuses code_tool_hook activation when host provenance is unresolvable — structured reason + nextAction, no writer constructed (review P1)', async () => {
1999
+ vi.mocked(resolveWorkspaceToolSemantics).mockReturnValueOnce({
2000
+ ok: false,
2001
+ reason: 'host_tool_declaration_missing',
2002
+ nextAction: 'start the workspace host once so it persists its tool declaration',
2003
+ });
2004
+ await handleRuntimeActivationDispatch({
2005
+ workspace: WS,
2006
+ artifactId: 'art-001',
2007
+ channel: 'code_tool_hook',
2008
+ json: true,
2009
+ });
2010
+
2011
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
2012
+ expect(output.decision).toBe('refused');
2013
+ expect(output.reason).toBe('host_tool_declaration_missing');
2014
+ expect(output.nextAction).toContain('host');
2015
+ // Fail-closed: the dispatcher (and its writers) must never run —
2016
+ // cli-5-failure-no-mutation.
2017
+ expect(mockRuleHostWriterConfigs).toHaveLength(0);
2018
+ expect(process.exitCode).toBe(1);
2019
+ });
2020
+
2021
+ it('non-code_tool_hook channels are unaffected by unresolvable provenance', async () => {
2022
+ vi.mocked(resolveWorkspaceToolSemantics).mockReturnValueOnce({
2023
+ ok: false,
2024
+ reason: 'host_tool_declaration_missing',
2025
+ nextAction: 'start the workspace host once',
2026
+ });
2027
+ await handleRuntimeActivationDispatch({
2028
+ workspace: WS,
2029
+ artifactId: 'art-001',
2030
+ channel: 'prompt',
2031
+ json: true,
2032
+ });
2033
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
2034
+ expect(output.decision).not.toBe('refused');
2035
+ });
2036
+
2037
+ it('code_tool_hook dispatch threads the resolved registry into RuleHostWriter and gateDeps', async () => {
2038
+ const registry = { version: 1, hasHostLayer: true, resolve: () => 'write', lookup: () => null, hasHostTool: () => true };
2039
+ vi.mocked(resolveWorkspaceToolSemantics).mockReturnValueOnce({ ok: true, registry, hostKind: 'openclaw' });
2040
+ await handleRuntimeActivationDispatch({
2041
+ workspace: WS,
2042
+ artifactId: 'art-001',
2043
+ channel: 'code_tool_hook',
2044
+ json: true,
2045
+ });
2046
+ expect(mockRuleHostWriterConfigs).toHaveLength(1);
2047
+ expect(mockRuleHostWriterConfigs[0]?.toolSemantics).toBe(registry);
2048
+ expect(mockRuleHostWriterConfigs[0]?.gateDeps).toBeDefined();
2049
+ });
2050
+ });
@@ -0,0 +1,269 @@
1
+ /**
2
+ * PRI-661 — run-once evaluator runtime parity (E2E through the REAL CLI
3
+ * handler).
4
+ *
5
+ * Drives `handleRuntimeInternalizationRunOnce --runner evaluator` against a
6
+ * REAL sqlite workspace + REAL persisted host declaration + the REAL resolver
7
+ * and sandbox. Only the LLM boundary is the CLI's own test-double runtime.
8
+ *
9
+ * Pins the acceptance from the Linear issue:
10
+ * 1. a declared workspace runs the deterministic adversarial replay to
11
+ * completion through run-once (the gap this PR closes);
12
+ * 2. the SAME artifact replays to the SAME verdict through the
13
+ * consumer-cycle assembly formula (live registry from the same mapping
14
+ * constants) — replay/production parity, not just "some gate ran";
15
+ * 3. an undeclared workspace refuses BEFORE leasing any task, with the same
16
+ * structured semantics as runtime-activation (no baseline fallback);
17
+ * 4. a replay-failing rule records adversarialResult.passed=false — the
18
+ * deterministic verdict the downstream approval chain consumes.
19
+ */
20
+ import { describe, it, expect, afterEach, vi } from 'vitest';
21
+ import * as fs from 'node:fs';
22
+ import * as os from 'node:os';
23
+ import * as path from 'node:path';
24
+ import {
25
+ RuntimeStateManager,
26
+ createPITaskDiagnosticJson,
27
+ buildToolSemanticRegistry,
28
+ createProductionGateDeps,
29
+ type ToolSemanticMappingV1,
30
+ } from '@principles/core/runtime-v2';
31
+ import { saveHostToolDeclaration } from '@principles/host-runtime';
32
+ import { handleRuntimeInternalizationRunOnce } from '../../src/commands/runtime-internalization-run-once.js';
33
+
34
+ const HOST_MAPPINGS: readonly ToolSemanticMappingV1[] = [
35
+ { rawToolName: 'Write', canonicalKind: 'write' },
36
+ { rawToolName: 'Edit', canonicalKind: 'write' },
37
+ { rawToolName: 'write_file', canonicalKind: 'write' },
38
+ { rawToolName: 'Bash', canonicalKind: 'execute' },
39
+ ];
40
+
41
+ /**
42
+ * A realistic write-after-read guard that satisfies both the fixture's golden
43
+ * cases and the runner's 5 auto-generated v2 adversarial templates:
44
+ * risk-path writes always block (v1 action check dominates); otherwise a
45
+ * write is allowed only with prior-read evidence or an explicitly benign
46
+ * context posture (unavailable/truncated/unknown fail open).
47
+ * Registry-sensitive: it keys off action.canonicalKind — the field the host
48
+ * tool registry injects into replay inputs.
49
+ */
50
+ const PASSING_CODE = [
51
+ 'function evaluate(input) {',
52
+ ' const action = input && input.action ? input.action : {};',
53
+ ' const kind = action.canonicalKind ? action.canonicalKind : "other";',
54
+ ' const params = action.paramsSummary ? action.paramsSummary : {};',
55
+ ' const p = typeof params.path === "string" ? params.path : "";',
56
+ ' if (kind !== "write") {',
57
+ ' return { decision: "allow", matched: false, reason: "not a write" };',
58
+ ' }',
59
+ ' if (p.indexOf("/etc/") === 0) {',
60
+ ' return { decision: "block", matched: true, reason: "risk path write" };',
61
+ ' }',
62
+ ' const ctx = input && input.context ? input.context : null;',
63
+ ' const facts = ctx && ctx.facts ? ctx.facts : {};',
64
+ ' if (facts.priorReadOfTarget === "yes") {',
65
+ ' return { decision: "allow", matched: false, reason: "write after read" };',
66
+ ' }',
67
+ ' if (facts.priorReadOfTarget === "no") {',
68
+ ' return { decision: "block", matched: true, reason: "write without prior read" };',
69
+ ' }',
70
+ ' return { decision: "allow", matched: false, reason: "unknown history fails open" };',
71
+ '}',
72
+ ].join('\n');
73
+
74
+ /** Same shape, but never blocks — the negative golden case fails under replay. */
75
+ const REPLAY_FAILING_CODE = [
76
+ 'function evaluate(input) {',
77
+ ' return { decision: "allow", matched: false, reason: "never blocks" };',
78
+ '}',
79
+ ].join('\n');
80
+
81
+ const GOLDEN_CASES = [
82
+ { caseId: 'c-neg', kind: 'negative', toolName: 'write_file', params: { path: '/etc/passwd' }, expectedDecision: 'block' },
83
+ { caseId: 'c-pos', kind: 'positive', toolName: 'write_file', params: { path: '/project/src/safe.ts' }, expectedDecision: 'allow' },
84
+ ];
85
+
86
+ function codeBearingArtificerContent(implementationCode: string): string {
87
+ return JSON.stringify({
88
+ implementationCode,
89
+ goldenTraceCases: GOLDEN_CASES,
90
+ affectedTools: ['write_file'],
91
+ });
92
+ }
93
+
94
+ const dirs: string[] = [];
95
+
96
+ function makeWorkspace(withDeclaration: boolean): string {
97
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-runonce-parity-'));
98
+ dirs.push(dir);
99
+ if (withDeclaration) {
100
+ saveHostToolDeclaration(dir, {
101
+ version: 1,
102
+ hostKind: 'openclaw',
103
+ mappings: HOST_MAPPINGS,
104
+ declaredAt: new Date().toISOString(),
105
+ });
106
+ }
107
+ return dir;
108
+ }
109
+
110
+ afterEach(() => {
111
+ vi.restoreAllMocks();
112
+ for (const dir of dirs.splice(0)) {
113
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* temp */ }
114
+ }
115
+ });
116
+
117
+ /**
118
+ * Seed the dependency chain exactly like the core gate-authority fixture:
119
+ * artificer(succeeded) + its code-bearing artifact + evaluator(pending,
120
+ * dep=artificer). The artifact id matches the CLI test-double's default echo.
121
+ */
122
+ async function seedEvaluatorTask(workspaceDir: string, implementationCode: string): Promise<void> {
123
+ const stateManager = new RuntimeStateManager({ workspaceDir });
124
+ await stateManager.initialize();
125
+ try {
126
+ const meta = (deps: string[]): string =>
127
+ createPITaskDiagnosticJson({
128
+ dependencyTaskIds: deps, channel: 'prompt', timeoutMs: 300_000,
129
+ inputArtifactRefs: [], outputArtifactRefs: [],
130
+ });
131
+ await stateManager.createTask({ taskId: 'artificer-seed', taskKind: 'artificer', status: 'pending', attemptCount: 0, maxAttempts: 3, diagnosticJson: meta([]) });
132
+ await stateManager.acquireLease({ taskId: 'artificer-seed', owner: 'seed', runtimeKind: 'test-double' });
133
+ await stateManager.markTaskSucceeded('artificer-seed');
134
+ await stateManager.createTask({ taskId: 'evaluator-under-test', taskKind: 'evaluator', status: 'pending', attemptCount: 0, maxAttempts: 3, diagnosticJson: meta(['artificer-seed']) });
135
+ await stateManager.piArtifactStore.upsertArtifact({
136
+ artifactId: 'pi-art-test-artificer',
137
+ artifactKind: 'principle',
138
+ sourceTaskId: 'artificer-seed',
139
+ lineageArtifactIds: [],
140
+ validationStatus: 'validated',
141
+ contentJson: codeBearingArtificerContent(implementationCode),
142
+ createdAt: new Date().toISOString(),
143
+ updatedAt: new Date().toISOString(),
144
+ });
145
+ } finally {
146
+ await stateManager.close();
147
+ }
148
+ }
149
+
150
+ async function readEvaluatorArtifact(workspaceDir: string): Promise<Record<string, unknown> | null> {
151
+ const stateManager = new RuntimeStateManager({ workspaceDir });
152
+ await stateManager.initialize();
153
+ try {
154
+ const artifacts = await stateManager.piArtifactStore.listBySourceTaskId('evaluator-under-test');
155
+ const principle = artifacts.find((a) => a.artifactKind === 'principle');
156
+ return principle ? (JSON.parse(principle.contentJson) as Record<string, unknown>) : null;
157
+ } finally {
158
+ await stateManager.close();
159
+ }
160
+ }
161
+
162
+ /** The consumer-cycle assembly formula: live registry from the same constants a host passes in memory. */
163
+ function consumerCycleFormulaReplay(implementationCode: string): { passed: boolean; failedCaseCount: number } {
164
+ const liveRegistry = buildToolSemanticRegistry(HOST_MAPPINGS);
165
+ if (!liveRegistry.ok) throw new Error(liveRegistry.errors.join('; '));
166
+ const gateDeps = createProductionGateDeps({ toolSemantics: liveRegistry.registry });
167
+ // RUNTIME_CONTRACT: the gate structurally validates the trace at runtime;
168
+ // the fabricated minimal object intentionally omits the full GoldenTrace
169
+ // provenance fields, so the literal cannot satisfy the parameter type
170
+ // directly (same documented pattern as evaluator-gate-authority fixtures).
171
+ const result = gateDeps.evaluateInSandbox(implementationCode, {
172
+ version: 1,
173
+ cases: GOLDEN_CASES.map((c) => ({ ...c })),
174
+ createdAt: '2026-09-04T00:00:00.000Z',
175
+ } as never);
176
+ return {
177
+ passed: result.success,
178
+ failedCaseCount: result.failedCases.length,
179
+ };
180
+ }
181
+
182
+ async function runOnceJson(workspaceDir: string): Promise<{ output: Record<string, unknown>; logSpy: ReturnType<typeof vi.spyOn> }> {
183
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
184
+ const prevExitCode = process.exitCode;
185
+ try {
186
+ await handleRuntimeInternalizationRunOnce({
187
+ workspace: workspaceDir,
188
+ runner: 'evaluator',
189
+ runtime: 'test-double',
190
+ allowTestDouble: true,
191
+ enqueueNext: false,
192
+ json: true,
193
+ });
194
+ const printed = logSpy.mock.calls.map((c) => String(c[0])).filter((s) => s.trim().startsWith('{'));
195
+ expect(printed.length).toBeGreaterThan(0);
196
+ return { output: JSON.parse(printed[0]) as Record<string, unknown>, logSpy };
197
+ } finally {
198
+ vi.restoreAllMocks();
199
+ process.exitCode = prevExitCode;
200
+ }
201
+ }
202
+
203
+ describe('run-once evaluator runtime parity (PRI-661)', () => {
204
+ it('declared workspace: adversarial replay runs to completion and matches the consumer-cycle verdict', async () => {
205
+ const workspaceDir = makeWorkspace(true);
206
+ await seedEvaluatorTask(workspaceDir, PASSING_CODE);
207
+
208
+ const { output } = await runOnceJson(workspaceDir);
209
+ expect(output.decision).toBe('would_lease');
210
+ expect(output.runnerResult, JSON.stringify(output)).toMatchObject({ status: 'succeeded' });
211
+
212
+ const artifact = await readEvaluatorArtifact(workspaceDir);
213
+ expect(artifact).not.toBeNull();
214
+ const adversarial = (artifact?.adversarialResult ?? null) as { passed?: boolean; failedCases?: unknown[] } | null;
215
+ expect(adversarial, 'deterministic replay must record adversarialResult').not.toBeNull();
216
+ expect(adversarial?.passed).toBe(true);
217
+ expect(adversarial?.failedCases ?? []).toEqual([]);
218
+
219
+ // Parity: the SAME code replays to the SAME verdict through the
220
+ // consumer-cycle assembly formula (live registry, same constants).
221
+ expect(consumerCycleFormulaReplay(PASSING_CODE)).toEqual({ passed: true, failedCaseCount: 0 });
222
+ }, 60_000);
223
+
224
+ it('declared workspace: a replay-failing rule records passed=false with structured failed cases', async () => {
225
+ const workspaceDir = makeWorkspace(true);
226
+ await seedEvaluatorTask(workspaceDir, REPLAY_FAILING_CODE);
227
+
228
+ const { output } = await runOnceJson(workspaceDir);
229
+
230
+ const artifact = await readEvaluatorArtifact(workspaceDir);
231
+ expect(artifact).not.toBeNull();
232
+ const adversarial = (artifact?.adversarialResult ?? null) as { passed?: boolean; failedCases?: Array<{ caseId?: string }> } | null;
233
+ expect(adversarial).not.toBeNull();
234
+ expect(adversarial?.passed).toBe(false);
235
+ // The auto-generated v2 templates expecting block are the ones a
236
+ // never-blocking rule fails (path-boundary + combination at minimum).
237
+ const failedIds = (adversarial?.failedCases ?? []).map((c) => c.caseId);
238
+ expect(failedIds).toContain('v2-path-boundary');
239
+ expect(failedIds).toContain('v2-combination');
240
+
241
+ // The deterministic verdict the approval chain would consume matches the
242
+ // consumer-cycle formula for the same artifact content.
243
+ expect(consumerCycleFormulaReplay(REPLAY_FAILING_CODE)).toMatchObject({ passed: false });
244
+ void output;
245
+ }, 60_000);
246
+
247
+ it('undeclared workspace: refuses BEFORE leasing, structured reason, queue untouched', async () => {
248
+ const workspaceDir = makeWorkspace(false);
249
+ await seedEvaluatorTask(workspaceDir, PASSING_CODE);
250
+
251
+ const { output } = await runOnceJson(workspaceDir);
252
+ expect(output.decision).toBe('refused');
253
+ expect(output.reason).toBe('host_tool_declaration_missing');
254
+ expect(typeof output.nextAction).toBe('string');
255
+ expect((output.nextAction as string).length).toBeGreaterThan(0);
256
+
257
+ // cli-5: refusal performed NO queue mutation — the evaluator task is
258
+ // still pending and unleased.
259
+ const stateManager = new RuntimeStateManager({ workspaceDir });
260
+ await stateManager.initialize();
261
+ try {
262
+ const task = await stateManager.getTask('evaluator-under-test');
263
+ expect(task?.status).toBe('pending');
264
+ expect(task?.leasedBy ?? null).toBeNull();
265
+ } finally {
266
+ await stateManager.close();
267
+ }
268
+ }, 60_000);
269
+ });
@@ -170,6 +170,21 @@ vi.mock('../../src/config-reader.js', () => ({
170
170
  readOutputLanguageFromWorkspace: vi.fn().mockReturnValue({ outputLanguage: 'zh-CN' }),
171
171
  }));
172
172
 
173
+ // PRI-661: run-once now builds the evaluator replay context through the ONE
174
+ // host-runtime builder. Mock just that seam here — the unit tests below prove
175
+ // dispatch wiring; the real resolver + declaration fixture path is proven by
176
+ // runtime-internalization-run-once-evaluator-parity.test.ts.
177
+ const { mockCreateEvaluatorRuntimeContext } = vi.hoisted(() => {
178
+ const mockCreateEvaluatorRuntimeContext = vi.fn().mockReturnValue({
179
+ ok: true,
180
+ gateDeps: { evaluateInSandbox: vi.fn() },
181
+ });
182
+ return { mockCreateEvaluatorRuntimeContext };
183
+ });
184
+ vi.mock('@principles/host-runtime', () => ({
185
+ createEvaluatorRuntimeContext: mockCreateEvaluatorRuntimeContext,
186
+ }));
187
+
173
188
  import { handleRuntimeInternalizationRunOnce } from '../../src/commands/runtime-internalization-run-once.js';
174
189
 
175
190
  const WS = '/fake/workspace';
@@ -1136,6 +1151,28 @@ describe('handleRuntimeInternalizationRunOnce', () => {
1136
1151
  expect(output.runnerResult.status).toBe('succeeded');
1137
1152
  });
1138
1153
 
1154
+ it('--runner evaluator refuses BEFORE leasing when the runtime context is unresolvable (PRI-661)', async () => {
1155
+ mockCreateEvaluatorRuntimeContext.mockReturnValueOnce({
1156
+ ok: false,
1157
+ reason: 'host_tool_declaration_missing',
1158
+ nextAction: 'start a host (OpenClaw/Codex) once so it persists its tool declaration',
1159
+ });
1160
+
1161
+ await handleRuntimeInternalizationRunOnce({ workspace: WS, runner: 'evaluator', runtime: 'test-double', allowTestDouble: true, json: true });
1162
+
1163
+ // cli-5: refusal must not lease/advance any task.
1164
+ expect(mockWakeOnce).not.toHaveBeenCalled();
1165
+ const EvaluatorRunnerMock = vi.mocked(
1166
+ await import('@principles/core/runtime-v2').then(m => m.EvaluatorRunner),
1167
+ );
1168
+ expect(EvaluatorRunnerMock).not.toHaveBeenCalled();
1169
+
1170
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0]);
1171
+ expect(output.decision).toBe('refused');
1172
+ expect(output.reason).toBe('host_tool_declaration_missing');
1173
+ expect(output.nextAction).toContain('host');
1174
+ });
1175
+
1139
1176
  it('auto-enqueue: --runner evaluator returns successor decision', async () => {
1140
1177
  mockWakeOnce.mockResolvedValue({
1141
1178
  decision: 'would_lease',
@@ -37,6 +37,8 @@ import {
37
37
  createSandboxGateDeps,
38
38
  } from '../../src/services/rulehost-pipeline-runner.js';
39
39
  import { RuleHost } from '../../../openclaw-plugin/src/core/rule-host.js';
40
+ import { OPENCLAW_TOOL_SEMANTIC_MAPPINGS } from '../../../openclaw-plugin/src/constants/tool-semantics.js';
41
+ import { saveHostToolDeclaration } from '@principles/host-runtime';
40
42
  import type { CodeRuleCapability } from '../../src/services/rulehost-pipeline-runner.js';
41
43
  import type { PDRuntimeAdapter, RunHandle, RunStatus, PIArtifactStore, RuntimeCapabilities, RuntimeHealth, RuntimeArtifactRef, ContextItem, StructuredRunOutput, StartRunInput } from '@principles/core/runtime-v2';
42
44
  import {
@@ -204,6 +206,16 @@ let tmpDir = '';
204
206
  function makeTmpDir(): string {
205
207
  const dir = path.join(os.tmpdir(), `pd-xpkg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
206
208
  fs.mkdirSync(dir, { recursive: true });
209
+ // PRI-661: the pipeline's evaluator replay resolves the production gate
210
+ // context from durable workspace provenance. Seed the declaration the real
211
+ // OpenClaw host would persist on startup (same mapping constants), so the
212
+ // generation-time replay and the activation gate see the same registry.
213
+ saveHostToolDeclaration(dir, {
214
+ version: 1,
215
+ hostKind: 'openclaw',
216
+ mappings: OPENCLAW_TOOL_SEMANTIC_MAPPINGS,
217
+ declaredAt: new Date().toISOString(),
218
+ });
207
219
  return dir;
208
220
  }
209
221
 
@@ -20,6 +20,7 @@ import { runRuleHostPipeline } from '../../src/services/rulehost-pipeline-runner
20
20
  import type { CodeRuleCapability } from '../../src/services/rulehost-pipeline-runner.js';
21
21
  import type { PDRuntimeAdapter, RunHandle, RunStatus, PIArtifactStore, RuntimeCapabilities, RuntimeHealth, RuntimeArtifactRef, ContextItem, StructuredRunOutput, StartRunInput } from '@principles/core/runtime-v2';
22
22
  import { RuntimeStateManager, createPITaskDiagnosticJson } from '@principles/core/runtime-v2';
23
+ import { saveHostToolDeclaration } from '@principles/host-runtime';
23
24
 
24
25
  type StageFactory = (taskId: string, priorArtifactId?: string) => unknown;
25
26
  type EvaluatorFactory = (taskId: string, artificerArtifactId: string) => unknown;
@@ -192,6 +193,19 @@ let tmpDir = '';
192
193
  function makeTmpDir(): string {
193
194
  const dir = path.join(os.tmpdir(), `pd-pipe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
194
195
  fs.mkdirSync(dir, { recursive: true });
196
+ // PRI-661: the pipeline's evaluator replay resolves the production gate
197
+ // context from durable workspace provenance — seed a declaration like every
198
+ // real host does on startup.
199
+ saveHostToolDeclaration(dir, {
200
+ version: 1,
201
+ hostKind: 'testhost',
202
+ mappings: [
203
+ { rawToolName: 'Write', canonicalKind: 'write' },
204
+ { rawToolName: 'Edit', canonicalKind: 'write' },
205
+ { rawToolName: 'Bash', canonicalKind: 'execute' },
206
+ ],
207
+ declaredAt: new Date().toISOString(),
208
+ });
195
209
  return dir;
196
210
  }
197
211
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Workspace Tool Semantics Resolution tests — PRI-634-F R2 (review P1-2)
3
+ *
4
+ * The CLI must resolve the host registry from DURABLE workspace provenance
5
+ * and refuse (never guess, never silently skip) when unavailable.
6
+ */
7
+
8
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import path from 'node:path';
12
+ import { resolveWorkspaceToolSemantics } from '../../src/services/workspace-tool-semantics.js';
13
+
14
+ let ws: string;
15
+
16
+ beforeEach(() => {
17
+ ws = mkdtempSync(path.join(tmpdir(), 'pd-wts-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(ws, { recursive: true, force: true });
22
+ });
23
+
24
+ function writeDeclaration(payload: unknown, hostKind = 'openclaw'): void {
25
+ // R3: per-host partition — <ws>/.pd/host-tool-semantics/<hostKind>.json
26
+ mkdirSync(path.join(ws, '.pd', 'host-tool-semantics'), { recursive: true });
27
+ writeFileSync(path.join(ws, '.pd', 'host-tool-semantics', `${hostKind}.json`), JSON.stringify(payload), 'utf8');
28
+ }
29
+
30
+ describe('resolveWorkspaceToolSemantics', () => {
31
+ it('resolves a valid persisted host declaration into a host-layered registry', () => {
32
+ writeDeclaration({
33
+ version: 1,
34
+ hostKind: 'openclaw',
35
+ mappings: [
36
+ { rawToolName: 'shell', canonicalKind: 'execute' },
37
+ { rawToolName: 'write_file', canonicalKind: 'write' },
38
+ ],
39
+ declaredAt: '2026-09-04T00:00:00.000Z',
40
+ });
41
+ const result = resolveWorkspaceToolSemantics(ws);
42
+ expect(result.ok).toBe(true);
43
+ if (!result.ok) return;
44
+ expect(result.hostKind).toBe('openclaw');
45
+ expect(result.registry.hasHostTool('shell')).toBe(true);
46
+ // Core baseline names still classify, but are NOT host-declared here.
47
+ expect(result.registry.hasHostTool('execute_command')).toBe(false);
48
+ expect(result.registry.hasHostTool('read_file')).toBe(false);
49
+ });
50
+
51
+ it('missing declaration → structured failure with actionable nextAction (no guessing)', () => {
52
+ const result = resolveWorkspaceToolSemantics(ws);
53
+ expect(result.ok).toBe(false);
54
+ if (result.ok) return;
55
+ expect(result.reason).toBe('host_tool_declaration_missing');
56
+ expect(result.nextAction).toContain('host');
57
+ });
58
+
59
+ it('malformed declaration → host_tool_declaration_invalid, never baseline fallback', () => {
60
+ writeDeclaration({ version: 1, hostKind: 'x', mappings: [{ rawToolName: 'bad', canonicalKind: 'nope' }], declaredAt: 't' });
61
+ const result = resolveWorkspaceToolSemantics(ws);
62
+ expect(result.ok).toBe(false);
63
+ if (result.ok) return;
64
+ expect(result.reason).toContain('host_tool_declaration_invalid');
65
+ expect(result.reason).toContain('openclaw');
66
+ });
67
+
68
+ it('not JSON at all → host_tool_declaration_invalid (per-host file)', () => {
69
+ mkdirSync(path.join(ws, '.pd', 'host-tool-semantics'), { recursive: true });
70
+ writeFileSync(path.join(ws, '.pd', 'host-tool-semantics', 'openclaw.json'), '{not json', 'utf8');
71
+ const result = resolveWorkspaceToolSemantics(ws);
72
+ expect(result.ok).toBe(false);
73
+ if (result.ok) return;
74
+ expect(result.reason).toBe('host_tool_declaration_invalid (openclaw: not valid JSON)');
75
+ });
76
+ });