@principles/host-runtime 0.7.3 → 0.7.5

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.
@@ -1,3 +1,60 @@
1
1
  import type { HostLivenessContract } from '@principles/core/runtime-v2';
2
2
  /** Adapter-owned facts consumed by the non-bypassable RuleCode promotion gate. */
3
3
  export declare const OPENCLAW_HOST_LIVENESS_CONTRACT: HostLivenessContract;
4
+ export type PromotionHostLivenessResolution = {
5
+ ok: true;
6
+ hostKind: 'openclaw';
7
+ /**
8
+ * The workspace host DECLARATIONS this resolution is based on.
9
+ * Empty array = nothing declared yet (the OpenClaw contract is the
10
+ * documented historical default; review S3: callers can distinguish an
11
+ * undeclared workspace from a declared-OpenClaw one);
12
+ * `['openclaw']` = the OpenClaw host actually declared itself.
13
+ */
14
+ hostKinds: readonly string[];
15
+ hostContract: HostLivenessContract;
16
+ hostRuntimeVersion: string;
17
+ } | {
18
+ ok: false;
19
+ /** Stable machine-readable fail-closed reason (also surfaced as the hostRuntimeVersion marker in failure snapshots / capability reporting). */
20
+ reason: 'host_declarations_unreadable' | 'workspace_provenance_unreadable' | 'promotion_host_unsupported' | 'multi_host_promotion_ambiguous' | 'host_declaration_missing_for_configured_host';
21
+ hostKinds: readonly string[];
22
+ hostContract: null;
23
+ hostRuntimeVersion: null;
24
+ nextAction: string;
25
+ };
26
+ /**
27
+ * PRI-813: resolve the promotion host-liveness contract from the workspace's
28
+ * REAL host state instead of unconditionally inheriting the OpenClaw
29
+ * contract (which made Codex workspaces report runtime_compatibility /
30
+ * runtime_shadow_evidence as passed — a false capability claim; the promotion
31
+ * then only ever failed on sample count, hiding the unsupported truth).
32
+ *
33
+ * Two durable provenance sources, cross-checked (Owner review round-4):
34
+ * 1. host declarations — `.pd/host-tool-semantics/<hostKind>.json`;
35
+ * 2. trajectory.db `pain_events.host_kind` — which hosts actually produced
36
+ * governance evidence here (survives declaration deletion).
37
+ *
38
+ * Resolution rules (fail closed outside the one host with an approved
39
+ * capability authority, and fail closed whenever Codex evidence exists but
40
+ * its declaration is missing — never silently fall back to OpenClaw):
41
+ * - no declaration AND no Codex evidence → OpenClaw contract (the historical
42
+ * governance default; a workspace where only OpenClaw ever ran — or no
43
+ * host ran — is exactly the legacy state, and existing workspaces rely
44
+ * on it);
45
+ * - only openclaw declared and no Codex evidence → OPENCLAW_HOST_LIVENESS_CONTRACT;
46
+ * - codex declared (alone) → promotion_host_unsupported (Codex promotion
47
+ * stays explicitly unsupported until an evidence-backed capability
48
+ * authority decision exists — no second host contract here);
49
+ * - multiple hosts declared → multi_host_promotion_ambiguous (never guess a
50
+ * target host; the Owner decides host-selection policy separately);
51
+ * - Codex evidence present but codex.json missing (deleted/corrupted —
52
+ * alone or alongside openclaw.json) → host_declaration_missing_for_configured_host
53
+ * (re-persist the declaration before promoting);
54
+ * - declarations or provenance unreadable → fail closed with the reason.
55
+ *
56
+ * This function intentionally creates no Codex contract, no capability
57
+ * registry, and no new store — it only routes the existing single authority
58
+ * truthfully over state that already exists.
59
+ */
60
+ export declare function resolvePromotionHostLiveness(workspaceDir: string): PromotionHostLivenessResolution;
@@ -1,3 +1,7 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { loadHostToolDeclarations } from './host-tool-declaration.js';
1
5
  /** Adapter-owned facts consumed by the non-bypassable RuleCode promotion gate. */
2
6
  export const OPENCLAW_HOST_LIVENESS_CONTRACT = {
3
7
  version: 'openclaw-legacy@1',
@@ -16,3 +20,125 @@ export const OPENCLAW_HOST_LIVENESS_CONTRACT = {
16
20
  { probeId: 'probe-owner-review', capabilityId: 'owner_review_access', toolName: 'owner_review_access', params: {}, expectedDecision: 'allow' },
17
21
  ],
18
22
  };
23
+ /**
24
+ * Existing durable provenance (Owner review round-4): `trajectory.db`
25
+ * `pain_events.host_kind` records which host actually produced governance
26
+ * evidence in this workspace ('codex' rows are written by the Codex
27
+ * ingestion/admission path). It survives deletion of
28
+ * `.pd/host-tool-semantics/<hostKind>.json`, which is exactly the
29
+ * declaration-lost state a silent OpenClaw fallback would misread.
30
+ * Absent file = no evidence (normal for quiet workspaces). A pain_events
31
+ * table predating the host_kind column carries no host evidence BY
32
+ * CONSTRUCTION (nothing could have been recorded before the column existed)
33
+ * — equivalent to empty, not unreadable. Read-only, never mutates state.
34
+ */
35
+ function readWorkspaceHostProvenance(workspaceDir) {
36
+ const dbPath = path.join(workspaceDir, '.state', 'trajectory.db');
37
+ if (!fs.existsSync(dbPath))
38
+ return { ok: true, kinds: [] };
39
+ let db;
40
+ try {
41
+ db = new Database(dbPath, { readonly: true });
42
+ }
43
+ catch (error) {
44
+ return { ok: false, nextAction: `inspect the workspace trajectory database before promoting: ${error instanceof Error ? error.message : String(error)}` };
45
+ }
46
+ try {
47
+ const columns = db.prepare('PRAGMA table_info(pain_events)').all();
48
+ const hasHostKindColumn = Array.isArray(columns)
49
+ && columns.some((column) => typeof column === 'object' && column !== null && column.name === 'host_kind');
50
+ if (!hasHostKindColumn)
51
+ return { ok: true, kinds: [] };
52
+ const rows = db.prepare('SELECT DISTINCT host_kind FROM pain_events WHERE host_kind IS NOT NULL').all();
53
+ if (!Array.isArray(rows)) {
54
+ return { ok: false, nextAction: 'inspect the workspace trajectory database pain_events integrity before promoting' };
55
+ }
56
+ const kinds = rows
57
+ .filter((row) => typeof row === 'object' && row !== null)
58
+ .map(row => row.host_kind)
59
+ .filter((kind) => typeof kind === 'string');
60
+ return { ok: true, kinds };
61
+ }
62
+ catch (error) {
63
+ return { ok: false, nextAction: `inspect the workspace trajectory database (locked, or damaged pain_events table) before promoting: ${error instanceof Error ? error.message : String(error)}` };
64
+ }
65
+ finally {
66
+ try {
67
+ db.close();
68
+ }
69
+ catch { /* best effort */ }
70
+ }
71
+ }
72
+ function promotionHostFailure(reason, hostKinds, nextAction) {
73
+ return { ok: false, reason, hostKinds, hostContract: null, hostRuntimeVersion: null, nextAction };
74
+ }
75
+ /**
76
+ * PRI-813: resolve the promotion host-liveness contract from the workspace's
77
+ * REAL host state instead of unconditionally inheriting the OpenClaw
78
+ * contract (which made Codex workspaces report runtime_compatibility /
79
+ * runtime_shadow_evidence as passed — a false capability claim; the promotion
80
+ * then only ever failed on sample count, hiding the unsupported truth).
81
+ *
82
+ * Two durable provenance sources, cross-checked (Owner review round-4):
83
+ * 1. host declarations — `.pd/host-tool-semantics/<hostKind>.json`;
84
+ * 2. trajectory.db `pain_events.host_kind` — which hosts actually produced
85
+ * governance evidence here (survives declaration deletion).
86
+ *
87
+ * Resolution rules (fail closed outside the one host with an approved
88
+ * capability authority, and fail closed whenever Codex evidence exists but
89
+ * its declaration is missing — never silently fall back to OpenClaw):
90
+ * - no declaration AND no Codex evidence → OpenClaw contract (the historical
91
+ * governance default; a workspace where only OpenClaw ever ran — or no
92
+ * host ran — is exactly the legacy state, and existing workspaces rely
93
+ * on it);
94
+ * - only openclaw declared and no Codex evidence → OPENCLAW_HOST_LIVENESS_CONTRACT;
95
+ * - codex declared (alone) → promotion_host_unsupported (Codex promotion
96
+ * stays explicitly unsupported until an evidence-backed capability
97
+ * authority decision exists — no second host contract here);
98
+ * - multiple hosts declared → multi_host_promotion_ambiguous (never guess a
99
+ * target host; the Owner decides host-selection policy separately);
100
+ * - Codex evidence present but codex.json missing (deleted/corrupted —
101
+ * alone or alongside openclaw.json) → host_declaration_missing_for_configured_host
102
+ * (re-persist the declaration before promoting);
103
+ * - declarations or provenance unreadable → fail closed with the reason.
104
+ *
105
+ * This function intentionally creates no Codex contract, no capability
106
+ * registry, and no new store — it only routes the existing single authority
107
+ * truthfully over state that already exists.
108
+ */
109
+ export function resolvePromotionHostLiveness(workspaceDir) {
110
+ const loaded = loadHostToolDeclarations(workspaceDir);
111
+ if (!loaded.ok && loaded.reason !== 'host_tool_declaration_missing') {
112
+ return promotionHostFailure('host_declarations_unreadable', [], `repair the host tool declarations before promoting: ${loaded.reason} (${loaded.nextAction})`);
113
+ }
114
+ const declaredKinds = loaded.ok
115
+ ? [...new Set(loaded.declarations.map(declaration => declaration.hostKind))].sort()
116
+ : [];
117
+ const provenance = readWorkspaceHostProvenance(workspaceDir);
118
+ if (!provenance.ok) {
119
+ return promotionHostFailure('workspace_provenance_unreadable', declaredKinds, provenance.nextAction);
120
+ }
121
+ const provenanceKinds = provenance.kinds.filter(kind => kind === 'openclaw' || kind === 'codex');
122
+ const effectiveKinds = [...new Set([...declaredKinds, ...provenanceKinds])].sort();
123
+ // The promotion host can only be the OpenClaw contract when nothing on the
124
+ // workspace — neither a declaration nor behavioral evidence — says Codex.
125
+ if (!effectiveKinds.includes('codex')) {
126
+ return {
127
+ ok: true,
128
+ hostKind: 'openclaw',
129
+ hostKinds: declaredKinds,
130
+ hostContract: OPENCLAW_HOST_LIVENESS_CONTRACT,
131
+ hostRuntimeVersion: OPENCLAW_HOST_LIVENESS_CONTRACT.version,
132
+ };
133
+ }
134
+ if (!declaredKinds.includes('codex')) {
135
+ // Codex evidence exists (declaration gone, or only openclaw.json
136
+ // remains) — the exact state where a silent OpenClaw fallback would
137
+ // resurrect the false capability PASS (Owner review round-4).
138
+ return promotionHostFailure('host_declaration_missing_for_configured_host', effectiveKinds, 'Codex evidence exists in this workspace but .pd/host-tool-semantics/codex.json is missing; re-run the Codex worker so it re-persists its declaration (or deactivate the Codex activation) before promoting');
139
+ }
140
+ if (declaredKinds.length > 1) {
141
+ return promotionHostFailure('multi_host_promotion_ambiguous', effectiveKinds, `the workspace declares multiple hosts (${declaredKinds.join(', ')}); promotion requires an unambiguous governing host — decide the promotion target host first`);
142
+ }
143
+ return promotionHostFailure('promotion_host_unsupported', effectiveKinds, `host '${declaredKinds[0]}' has no approved promotion capability authority; promotion on this host is explicitly unsupported until an evidence-backed capability decision exists`);
144
+ }
@@ -1,3 +1,25 @@
1
+ /**
2
+ * Shared host-neutral internalization consumer cycle (PRI-624 Slice C).
3
+ *
4
+ * Extracted verbatim from openclaw-plugin's
5
+ * `internalization-auto-consumer-service.ts` `runConsumerCycle` so that the
6
+ * OpenClaw auto-consumer scheduler and the Companion workspace worker call
7
+ * ONE downstream execution implementation (SPEC §13: "reusing existing
8
+ * downstream consumer logic" — the Companion must not copy it):
9
+ *
10
+ * flag gate → config/runtime config → state handle → orchestrator(dryRun)
11
+ * → queue read model → consumer decision → wakeOnce loop → adapter
12
+ * → runner construction (dreamer…rollout_reviewer) → runner.run
13
+ * → commit proposal → finally: recovery sweep + reconciliation budget
14
+ *
15
+ * The lease is acquired INSIDE runner.run by the Runtime V2 lease manager
16
+ * (persisted in `<workspace>/.pd/state.db`, cross-process safe) — this cycle
17
+ * never leases by itself, so two schedulers (OpenClaw + Companion) racing on
18
+ * one workspace converge to exactly-once execution via lease_conflict.
19
+ *
20
+ * Hosts inject: a logger, a structured-event sink, their tool catalog, and an
21
+ * env getter. Everything else is the existing production path.
22
+ */
1
23
  import { type ToolSemanticRegistry } from '@principles/core/runtime-v2';
2
24
  import { type ConsumerGovernanceLogger } from './internalization-consumer-governance.js';
3
25
  export declare const INTERNALIZATION_AUTO_CONSUMER_FLAG_ID = "internalization_auto_consumer";
@@ -20,7 +20,6 @@
20
20
  * Hosts inject: a logger, a structured-event sink, their tool catalog, and an
21
21
  * env getter. Everything else is the existing production path.
22
22
  */
23
- import { createHash } from 'node:crypto';
24
23
  import { createRuntimeStateHandle, InternalizationOrchestrator, DreamerRunner, PhilosopherRunner, ScribeRunner, ArtificerRunner, buildArtificerHostSemanticContext, EvaluatorRunner, RolloutReviewerRunner, DefaultDreamerValidator, DefaultPhilosopherValidator, DefaultScribeValidator, DefaultArtificerValidator, DefaultEvaluatorValidator, DefaultRolloutReviewerValidator, PiAiRuntimeAdapter, L2AgentLoopAdapter, ArtificerL2Adapter, buildL2PrincipleReaderFromLedger, OpenClawCliRuntimeAdapter, storeEmitter, createProductionGateDeps, resolveRuntimeConfigFromPdConfig, resolveRuntimeConfigForAgent, AGENT_NAME_FOR_TASK_KIND, isRuntimeConfigError, computeConsumerDecision, FULL_CHAIN_CONSUMER_RUNNER_KINDS, DEFAULT_CONSUMER_RUNNER_KINDS, InternalizationQueueReadModel, MVP_CORE_TASK_KINDS, SqliteConnection, SqliteReconciliationCursorStore, SUCCEEDED_TRANSITIONS_SCOPE,
25
24
  // PRI-714: resolve outputLanguage from the same effective config the
26
25
  // runners already receive (EP-07: canonical resolved value, not raw input).
@@ -30,9 +29,6 @@ import { loadPdConfigForPlugin, loadFeatureFlagFromConfig } from './pd-config.js
30
29
  import { createEvaluatorRepairDeps, createRolloutGovernanceDeps } from './internalization-consumer-governance.js';
31
30
  import { WorkspaceTelemetryEmitter } from './workspace-telemetry-emitter.js';
32
31
  export const INTERNALIZATION_AUTO_CONSUMER_FLAG_ID = 'internalization_auto_consumer';
33
- function contentHashFn(text) {
34
- return createHash('sha256').update(text).digest('hex');
35
- }
36
32
  function formatRunOnceCommand(workspaceDir) {
37
33
  return `pd runtime internalization run-once --workspace "${workspaceDir}" --runner dreamer --runtime config --json`;
38
34
  }
@@ -311,6 +307,19 @@ export async function runInternalizationConsumerCycle(workspaceDir, ports) {
311
307
  // PRI-719: everything downstream reads the LEASED TASK's resolved config —
312
308
  // the kind-specific profile, not the shared gate resolution.
313
309
  const taskRuntimeKind = taskRuntimeConfig.runtimeKind;
310
+ // PRI-634 A3: workspace-scoped telemetry sink. Constructed here (per-wake,
311
+ // workspaceDir in scope) so events from THIS workspace's runners are
312
+ // attributable to THIS workspace — a global subscriber on the storeEmitter
313
+ // singleton cannot (multi-workspace isolation, see
314
+ // workspace-telemetry-emitter.ts). Persist failures degrade through the
315
+ // host's structured event port, never break the runner.
316
+ // PRI-795 review P1: shared by the EvaluatorRunner AND the ArtificerL2
317
+ // adapter so `artificer_l2_complete` evidence (abortOwner/budget/elapsed/
318
+ // stopReason/tokenUsage) persists durably instead of vanishing with the
319
+ // process (the EP002-R3 "nothing recoverable after a failure" class).
320
+ const workspaceEmitter = new WorkspaceTelemetryEmitter(storeEmitter, workspaceDir, (detail) => {
321
+ emitEvent('WORKSPACE_TELEMETRY_PERSIST_FAILED', detail);
322
+ });
314
323
  let adapter;
315
324
  if (taskRuntimeKind === 'pi-ai') {
316
325
  // PRI-419: when l2_dreamer flag is on AND this is a dreamer task, route
@@ -342,6 +351,13 @@ export async function runInternalizationConsumerCycle(workspaceDir, ports) {
342
351
  // PRI-758 CodeRabbit P2: forward maxTokens + systemPrompt from profile.
343
352
  ...(taskRuntimeConfig.maxTokens !== undefined ? { maxTokens: taskRuntimeConfig.maxTokens } : {}),
344
353
  ...(taskRuntimeConfig.systemPrompt ? { systemPrompt: taskRuntimeConfig.systemPrompt } : {}),
354
+ // PRI-795: forward the profile reasoning level — the PiAiRuntimeAdapter
355
+ // branch below already receives it; the L2 loop was silently dropping
356
+ // it, leaving always-thinking models at their most expensive default.
357
+ ...(taskRuntimeConfig.reasoning !== undefined ? { reasoning: taskRuntimeConfig.reasoning } : {}),
358
+ // PRI-795 review P1: the completion evidence must land in the
359
+ // workspace's durable telemetry sink, not the in-process singleton.
360
+ eventEmitter: workspaceEmitter,
345
361
  });
346
362
  }
347
363
  else if (l2Flag.enabled && wakeResult.taskKind === 'dreamer') {
@@ -429,25 +445,24 @@ export async function runInternalizationConsumerCycle(workspaceDir, ports) {
429
445
  // subscriber on the storeEmitter singleton cannot (multi-workspace
430
446
  // isolation, see workspace-telemetry-emitter.ts). Persist failures
431
447
  // degrade through the host's structured event port, never break the runner.
432
- const evaluatorEmitter = new WorkspaceTelemetryEmitter(storeEmitter, workspaceDir, (detail) => {
433
- emitEvent('WORKSPACE_TELEMETRY_PERSIST_FAILED', detail);
434
- });
448
+ // (PRI-795 review P1: the emitter itself is constructed above, before the
449
+ // adapter dispatch, so the ArtificerL2 adapter can share it.)
435
450
  // Dispatch by leased task kind. Only kinds listed in
436
451
  // FULL_CHAIN_CONSUMER_RUNNER_KINDS can be leased here; anything else
437
452
  // (e.g. diagnostician) hits the default branch — fail loud if it does (EP-03).
438
453
  let runner;
439
454
  switch (taskKind) {
440
455
  case 'dreamer':
441
- runner = new DreamerRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDreamerValidator(), contentHashFn }, runnerOptions);
456
+ runner = new DreamerRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultDreamerValidator() }, runnerOptions);
442
457
  break;
443
458
  case 'philosopher':
444
- runner = new PhilosopherRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultPhilosopherValidator(), contentHashFn }, runnerOptions);
459
+ runner = new PhilosopherRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultPhilosopherValidator() }, runnerOptions);
445
460
  break;
446
461
  case 'scribe':
447
- runner = new ScribeRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultScribeValidator(), contentHashFn }, runnerOptions);
462
+ runner = new ScribeRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultScribeValidator() }, runnerOptions);
448
463
  break;
449
464
  case 'artificer':
450
- runner = new ArtificerRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultArtificerValidator(), contentHashFn },
465
+ runner = new ArtificerRunner({ stateManager, runtimeAdapter: adapter, eventEmitter: storeEmitter, artifactStore: stateManager.piArtifactStore, validator: new DefaultArtificerValidator() },
451
466
  // PRI-741: thread the host semantic projection into generation.
452
467
  { ...runnerOptions, ...(artificerHostSemanticContext !== undefined ? { hostSemanticContext: artificerHostSemanticContext } : {}) });
453
468
  break;
@@ -466,7 +481,7 @@ export async function runInternalizationConsumerCycle(workspaceDir, ports) {
466
481
  // 无论 OpenClaw 还是 Codex worker 执行, deterministic gate wiring
467
482
  // 必须存在 (第二个 options 参数, 绝不放第一个 deps 参数)。
468
483
  runner = new EvaluatorRunner({
469
- stateManager, runtimeAdapter: adapter, eventEmitter: evaluatorEmitter,
484
+ stateManager, runtimeAdapter: adapter, eventEmitter: workspaceEmitter,
470
485
  artifactStore: stateManager.piArtifactStore, validator: new DefaultEvaluatorValidator(),
471
486
  ...createEvaluatorRepairDeps(workspaceDir, stateManager, logger),
472
487
  }, {
@@ -142,6 +142,7 @@ export function createEvaluatorRepairDeps(workspaceDir, stateManager, logger) {
142
142
  diagnosticJson: createPITaskDiagnosticJson({
143
143
  dependencyTaskIds: [...params.inheritedDependencyTaskIds],
144
144
  channel: params.inheritedChannel,
145
+ pipelineMode: params.inheritedPipelineMode,
145
146
  timeoutMs: params.inheritedTimeoutMs,
146
147
  inputArtifactRefs: [...params.inheritedInputArtifactRefs],
147
148
  outputArtifactRefs: [],
@@ -199,7 +199,18 @@ export function createProductionRuleHostGate(options = {}) {
199
199
  continue;
200
200
  }
201
201
  const [row] = group;
202
- if (!row || row.action !== 'code_tool_hook_live_activate')
202
+ if (!row)
203
+ continue;
204
+ // PRI-813: shadow activations are no longer skipped here — they run
205
+ // through the SAME budget/content/context validation and
206
+ // evaluateBatch pipeline as live rows, but their results are
207
+ // collected as observations only and never join mergeDecisions
208
+ // (shadow observes, never enforces). The v2 gate below stays ahead
209
+ // of any evaluation, so Codex v2 shadow remains suspended.
210
+ const activationMode = row.action === 'code_tool_hook_live_activate'
211
+ ? 'live'
212
+ : row.action === 'code_tool_hook_shadow_activate' ? 'shadow' : null;
213
+ if (activationMode === null)
203
214
  continue;
204
215
  const activationId = row.activation_id;
205
216
  const artifactId = row.artifact_id;
@@ -220,9 +231,21 @@ export function createProductionRuleHostGate(options = {}) {
220
231
  const contentJson = contentRow.content_json;
221
232
  const returnedContentBytes = Buffer.byteLength(contentJson, 'utf8');
222
233
  if (contentRow.content_bytes > ARTIFACT_CONTENT_BYTES || returnedContentBytes > ARTIFACT_CONTENT_BYTES) {
234
+ // CodeRabbit CR-3: a shadow row's envelope overrun must only skip
235
+ // the shadow observation — pre-PRI-813 this return was unreachable
236
+ // for shadow rows, and letting it fire now would suppress live
237
+ // enforcement (shadow may never influence the live outcome).
238
+ if (activationMode === 'shadow') {
239
+ addWarning(warnings, `artifact_content_budget_exceeded: activation=${activationId} bytes=${Math.max(contentRow.content_bytes, returnedContentBytes)} maximum=${ARTIFACT_CONTENT_BYTES}`, 'reduce the active shadow artifact envelope and reactivate the rule; live enforcement is unaffected');
240
+ continue;
241
+ }
223
242
  return { decision: 'allow', source: event.source, warnings: [boundedWarning(`artifact_content_budget_exceeded: activation=${activationId} bytes=${Math.max(contentRow.content_bytes, returnedContentBytes)} maximum=${ARTIFACT_CONTENT_BYTES}`, 'reduce the active artifact envelope and reactivate the rule')], metadata: { evaluatedLiveRules: 0 } };
224
243
  }
225
244
  if (contentRow.content_bytes !== expectedContentBytes || returnedContentBytes !== contentRow.content_bytes) {
245
+ if (activationMode === 'shadow') {
246
+ addWarning(warnings, `artifact_content_size_changed: activation=${activationId}`, 'retry after the active shadow artifact update completes; live enforcement is unaffected');
247
+ continue;
248
+ }
226
249
  return { decision: 'allow', source: event.source, warnings: [boundedWarning(`artifact_content_size_changed: activation=${activationId}`, 'retry after the active artifact update completes')], metadata: { evaluatedLiveRules: 0 } };
227
250
  }
228
251
  try {
@@ -233,6 +256,10 @@ export function createProductionRuleHostGate(options = {}) {
233
256
  }
234
257
  const sourceBytes = Buffer.byteLength(content.implementationCode, 'utf8');
235
258
  if (sourceBytes > RULE_SOURCE_BYTES) {
259
+ if (activationMode === 'shadow') {
260
+ addWarning(warnings, `rule_source_budget_exceeded: activation=${activationId} bytes=${sourceBytes}`, `reduce the shadow RuleCode source below ${RULE_SOURCE_BYTES} bytes; live enforcement is unaffected`);
261
+ continue;
262
+ }
236
263
  return { decision: 'allow', source: event.source, warnings: [boundedWarning(`rule_source_budget_exceeded: activation=${activationId} bytes=${sourceBytes}`, `reduce each RuleCode source below ${RULE_SOURCE_BYTES} bytes`)], metadata: { evaluatedLiveRules: 0 } };
237
264
  }
238
265
  if (Object.hasOwn(content, 'requiresContextVersion')) {
@@ -265,7 +292,7 @@ export function createProductionRuleHostGate(options = {}) {
265
292
  continue;
266
293
  }
267
294
  const fallbackMeta = { name: activationId, version: '1', ruleId, coversCondition: 'all' };
268
- candidates.push({ implId: activationId, ruleId, principleId, meta: isRuleMeta(content.meta) ? content.meta : fallbackMeta, source: content.implementationCode });
295
+ candidates.push({ implId: activationId, ruleId, principleId, meta: isRuleMeta(content.meta) ? content.meta : fallbackMeta, source: content.implementationCode, activationMode });
269
296
  }
270
297
  catch (error) {
271
298
  addWarning(warnings, `implementation_unhealthy: ${error instanceof Error ? error.message : String(error)}`, 'fix the RuleCode and reactivate the rule');
@@ -280,27 +307,91 @@ export function createProductionRuleHostGate(options = {}) {
280
307
  derived: { estimatedLineChanges: estimateLineChanges({ toolName: input.toolName, params: input.params }), bashRisk: enrichment.bashRisk },
281
308
  ...(context ? { context } : {}),
282
309
  };
283
- const batchSourceBytes = candidates.reduce((sum, candidate) => sum + Buffer.byteLength(candidate.source, 'utf8'), 0);
284
- if (batchSourceBytes > RULE_BATCH_SOURCE_BYTES) {
285
- return { decision: 'allow', source: event.source, warnings: [boundedWarning(`rule_source_budget_exceeded: batchBytes=${batchSourceBytes}`, `reduce total active RuleCode below ${RULE_BATCH_SOURCE_BYTES} bytes`)], metadata: { evaluatedLiveRules: 0 } };
310
+ // CodeRabbit CR-3: live and shadow evaluate in SEPARATE batches so a
311
+ // shadow rule's timeout/budget failure can never suppress live
312
+ // enforcement (shadow observes; it must not influence the live
313
+ // outcome). Live keeps the exact pre-PRI-813 early-return semantics;
314
+ // every shadow-batch failure degrades to a warning and skips the
315
+ // shadow observation only.
316
+ const liveCandidates = candidates.filter((candidate) => candidate.activationMode === 'live');
317
+ const shadowCandidates = candidates.filter((candidate) => candidate.activationMode === 'shadow');
318
+ const liveBatchBytes = liveCandidates.reduce((sum, candidate) => sum + Buffer.byteLength(candidate.source, 'utf8'), 0);
319
+ if (liveBatchBytes > RULE_BATCH_SOURCE_BYTES) {
320
+ return { decision: 'allow', source: event.source, warnings: [boundedWarning(`rule_source_budget_exceeded: batchBytes=${liveBatchBytes}`, `reduce total active RuleCode below ${RULE_BATCH_SOURCE_BYTES} bytes`)], metadata: { evaluatedLiveRules: 0 } };
286
321
  }
287
- const batchSources = candidates.map((candidate) => ({ source: candidate.source, filename: `activation-${candidate.implId}` }));
288
- const remaining = remainingGateMs(startedAt);
322
+ let remaining = remainingGateMs(startedAt);
289
323
  if (remaining <= 0) {
290
324
  return { decision: 'allow', source: event.source, warnings: [boundedWarning('gate_deadline_exceeded', 'reduce active RuleCode count or source size and retry')], metadata: { evaluatedLiveRules: 0 } };
291
325
  }
292
- const batch = implementationRuntime.evaluateBatch(batchSources, hostInput, remaining);
293
- if (!batch.ok || !batch.results) {
294
- return { decision: 'allow', source: event.source, warnings: [boundedWarning(`${batch.reason ?? 'rule_batch_failed'}: ${batch.detail ?? 'unknown failure'}`, 'inspect active RuleCode resource use and repair or deactivate the unhealthy rule')], metadata: { evaluatedLiveRules: 0 } };
326
+ const liveBatch = implementationRuntime.evaluateBatch(liveCandidates.map((candidate) => ({ source: candidate.source, filename: `activation-${candidate.implId}` })), hostInput, remaining);
327
+ if (!liveBatch.ok || !liveBatch.results) {
328
+ return { decision: 'allow', source: event.source, warnings: [boundedWarning(`${liveBatch.reason ?? 'rule_batch_failed'}: ${liveBatch.detail ?? 'unknown failure'}`, 'inspect active RuleCode resource use and repair or deactivate the unhealthy rule')], metadata: { evaluatedLiveRules: 0 } };
295
329
  }
296
- const timedOutChild = batch.results.find((candidate) => !candidate.ok && candidate.error?.includes('timed out'));
330
+ const timedOutChild = liveBatch.results.find((candidate) => !candidate.ok && candidate.error?.includes('timed out'));
297
331
  if (timedOutChild) {
298
332
  return { decision: 'allow', source: event.source, warnings: [boundedWarning(`rule_batch_timeout: ${timedOutChild.error ?? 'unknown child timeout'}`, 'fix or deactivate the unhealthy RuleCode and retry')], metadata: { evaluatedLiveRules: 0 } };
299
333
  }
334
+ // PRI-813: one canonical evaluation fact per shadow activation —
335
+ // observations only. Shadow results never join mergeDecisions, never
336
+ // block/modify/require approval: they record what a live rule WOULD
337
+ // have decided (ACTIVATION_CHANNELS §3.4), mirroring the legacy
338
+ // RuleHost report's shadowDecisions. Every failure mode below skips
339
+ // shadow observation without touching the live decision.
340
+ const shadowEvaluations = [];
341
+ if (shadowCandidates.length > 0) {
342
+ const shadowBatchBytes = shadowCandidates.reduce((sum, candidate) => sum + Buffer.byteLength(candidate.source, 'utf8'), 0);
343
+ remaining = remainingGateMs(startedAt);
344
+ if (shadowBatchBytes > RULE_BATCH_SOURCE_BYTES) {
345
+ addWarning(warnings, `rule_source_budget_exceeded: shadowBatchBytes=${shadowBatchBytes}`, `reduce total shadow RuleCode below ${RULE_BATCH_SOURCE_BYTES} bytes; shadow observation was skipped, live enforcement is unaffected`);
346
+ }
347
+ else if (remaining <= 0) {
348
+ addWarning(warnings, 'gate_deadline_exceeded', 'shadow observation was skipped after the live evaluation budget; live enforcement is unaffected');
349
+ }
350
+ else {
351
+ const shadowBatch = implementationRuntime.evaluateBatch(shadowCandidates.map((candidate) => ({ source: candidate.source, filename: `activation-${candidate.implId}` })), hostInput, remaining);
352
+ const shadowTimedOut = shadowBatch.ok && shadowBatch.results
353
+ ? shadowBatch.results.find((candidate) => !candidate.ok && candidate.error?.includes('timed out'))
354
+ : undefined;
355
+ if (!shadowBatch.ok || !shadowBatch.results) {
356
+ addWarning(warnings, `${shadowBatch.ok ? 'rule_batch_failed' : shadowBatch.reason ?? 'rule_batch_failed'}: ${shadowBatch.ok ? 'unknown failure' : shadowBatch.detail ?? 'unknown failure'}`, 'inspect active shadow RuleCode; the shadow observation was skipped, live enforcement is unaffected');
357
+ }
358
+ else if (shadowTimedOut) {
359
+ addWarning(warnings, `rule_batch_timeout: ${shadowTimedOut.error ?? 'unknown child timeout'}`, 'fix or deactivate the unhealthy shadow RuleCode; the shadow observation was skipped, live enforcement is unaffected');
360
+ }
361
+ else {
362
+ for (let index = 0; index < shadowCandidates.length; index += 1) {
363
+ const candidate = shadowCandidates[index];
364
+ const batchResult = shadowBatch.results[index];
365
+ if (!candidate || !batchResult || !batchResult.ok) {
366
+ addWarning(warnings, `implementation_unhealthy: ${batchResult && !batchResult.ok ? batchResult.error ?? 'unknown child error' : 'rule_batch_result_missing'}`, 'fix the shadow RuleCode and reactivate the rule; live enforcement is unaffected');
367
+ continue;
368
+ }
369
+ const validation = validateRuleHostResult(batchResult.result);
370
+ if (!isRuleResult(batchResult.result)) {
371
+ addWarning(warnings, `invalid RuleHostResult: ${validation.errors.join('; ')}`, 'fix the shadow RuleCode result and reactivate the rule; live enforcement is unaffected');
372
+ continue;
373
+ }
374
+ const shadowResult = batchResult.result.matched
375
+ ? { ...batchResult.result, ruleId: candidate.ruleId, principleId: candidate.principleId }
376
+ : batchResult.result;
377
+ shadowEvaluations.push({
378
+ toolName: input.toolName,
379
+ filePath: action.normalizedPath,
380
+ matched: shadowResult.matched,
381
+ decision: shadowResult.decision,
382
+ ruleId: candidate.ruleId,
383
+ activationId: candidate.implId,
384
+ activationMode: 'shadow',
385
+ });
386
+ }
387
+ }
388
+ }
389
+ }
300
390
  const implementations = [];
301
- for (let index = 0; index < candidates.length; index += 1) {
302
- const candidate = candidates[index];
303
- const batchResult = batch.results[index];
391
+ const liveEvaluatedResults = [];
392
+ for (let index = 0; index < liveCandidates.length; index += 1) {
393
+ const candidate = liveCandidates[index];
394
+ const batchResult = liveBatch.results[index];
304
395
  if (!candidate || !batchResult) {
305
396
  addWarning(warnings, 'rule_batch_result_missing', 'inspect the RuleCode runtime result contract');
306
397
  continue;
@@ -318,18 +409,47 @@ export function createProductionRuleHostGate(options = {}) {
318
409
  ? { ...batchResult.result, ruleId: candidate.ruleId, principleId: candidate.principleId }
319
410
  : batchResult.result;
320
411
  implementations.push({ ...candidate, evaluate: () => validatedResult });
412
+ liveEvaluatedResults.push(validatedResult);
321
413
  }
322
414
  const result = mergeDecisions(implementations, hostInput, {
323
415
  warn(message) { addWarning(warnings, message, 'inspect the unhealthy activation and RuleCode output'); },
324
416
  });
417
+ // PRI-813 + CodeRabbit CR-4: attribute the winning live activation by
418
+ // REFERENCE IDENTITY — mergeDecisions returns the winning candidate's
419
+ // own result object for block/auto_correct, so indexOf is exact even
420
+ // when two live activations share a ruleId (the previous ruleId
421
+ // reverse-lookup could mis-attribute). Unattributable outcomes
422
+ // (requireApproval aggregate, no winner) carry no activationId.
423
+ const winnerIndex = result === undefined ? -1 : liveEvaluatedResults.indexOf(result);
424
+ const liveActivationId = winnerIndex >= 0 ? implementations[winnerIndex]?.implId : undefined;
425
+ // PRI-813: per-activation evaluation facts for host-side telemetry
426
+ // writers. HostEventResult.metadata is the designed channel for
427
+ // host-neutral evaluation facts; each entry maps losslessly onto the
428
+ // canonical RuleHostEvaluatedEventData contract.
429
+ const evaluations = [
430
+ {
431
+ toolName: input.toolName,
432
+ filePath: action.normalizedPath,
433
+ matched: result?.matched ?? false,
434
+ // CodeRabbit CR-5 / PRI-567: an EMPTY live set is 'no_rules_armed',
435
+ // not a live 'allow' — enforcement statistics must not read as if a
436
+ // live rule evaluated (the legacy writer already distinguishes
437
+ // this; the shared path now aligns).
438
+ decision: liveCandidates.length === 0 ? 'no_rules_armed' : result?.decision ?? 'allow',
439
+ ...(result?.ruleId !== undefined ? { ruleId: result.ruleId } : {}),
440
+ ...(liveActivationId !== undefined ? { activationId: liveActivationId } : {}),
441
+ activationMode: 'live',
442
+ },
443
+ ...shadowEvaluations,
444
+ ];
325
445
  if (result?.decision === 'block') {
326
446
  if (result.reason.trim().length === 0) {
327
447
  addWarning(warnings, 'deny_reason_missing', 'fix the RuleCode to return a non-empty block reason');
328
- return { decision: 'allow', source: event.source, warnings, metadata: { evaluatedLiveRules: implementations.length } };
448
+ return { decision: 'allow', source: event.source, warnings, metadata: { evaluatedLiveRules: implementations.length, evaluations } };
329
449
  }
330
- return { decision: 'deny', reason: result.reason, source: event.source, ...(warnings.length ? { warnings } : {}), metadata: { evaluatedLiveRules: implementations.length, ruleId: result.ruleId, principleId: result.principleId } };
450
+ return { decision: 'deny', reason: result.reason, source: event.source, ...(warnings.length ? { warnings } : {}), metadata: { evaluatedLiveRules: implementations.length, ruleId: result.ruleId, principleId: result.principleId, evaluations } };
331
451
  }
332
- return { decision: 'allow', source: event.source, ...(warnings.length ? { warnings } : {}), metadata: { evaluatedLiveRules: implementations.length, ruleDecision: result?.decision ?? 'allow' } };
452
+ return { decision: 'allow', source: event.source, ...(warnings.length ? { warnings } : {}), metadata: { evaluatedLiveRules: implementations.length, ruleDecision: result?.decision ?? 'allow', evaluations } };
333
453
  }
334
454
  catch (error) {
335
455
  addWarning(warnings, `activation_read_failed: ${error instanceof Error ? error.message : String(error)}`, 'inspect state.db schema and integrity');
@@ -15,13 +15,16 @@ import { StoreEventEmitter } from '@principles/core/runtime-v2';
15
15
  * (ERR-092)。正确粒度是 **workspace-scoped**:本 emitter 在 consumer
16
16
  * cycle 的 per-wake 装配处构造,只经手本 workspace runner 发出的事件。
17
17
  *
18
- * 持久化策略:**allowlist**,只落盘 4 类 critical events(与 telemetry-event
18
+ * 持久化策略:**allowlist**,只落盘 critical events(与 telemetry-event
19
19
  * schema 枚举同源),不做全量 telemetry 无差别写盘(日志量 + 隐私审计面
20
20
  * 无谓扩大):
21
21
  * - evaluator_adversarial_replay_skipped
22
22
  * - evaluator_adversarial_replay
23
23
  * - evaluator_rule_assembled
24
24
  * - evaluator_rule_assembly_failed
25
+ * - artificer_l2_complete(PRI-795 review P1:Artificer L2 的 completion
26
+ * 证据 — abortOwner/budgetMs/elapsedMs/stopReason/tokenUsage — 必须
27
+ * crash 前落盘,否则 EP002-R3 的「失败后什么都查不到」重演)
25
28
  *
26
29
  * 落点 `<workspaceDir>/.pd/telemetry/critical-events.jsonl`(JSONL,一行一
27
30
  * 事件,含完整 TelemetryEvent)。同步 append:事件量小(allowlist 限流),
@@ -34,6 +37,7 @@ const CRITICAL_EVENT_ALLOWLIST = new Set([
34
37
  'evaluator_adversarial_replay',
35
38
  'evaluator_rule_assembled',
36
39
  'evaluator_rule_assembly_failed',
40
+ 'artificer_l2_complete',
37
41
  ]);
38
42
  export class WorkspaceTelemetryEmitter extends StoreEventEmitter {
39
43
  upstream;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principles/host-runtime",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Shared host-neutral orchestration for Principles Disciple MVP-Core hook paths.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",