principles-disciple 1.167.1 → 1.168.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.
@@ -30,12 +30,31 @@ export interface RuleHostOptions {
30
30
  /** Workspace directory for SQLite access. Required for RuleHost to load active code_tool_hook activations. */
31
31
  workspaceDir?: string;
32
32
  }
33
+ export interface RuleHostObservedDecision extends RuleHostResult {
34
+ readonly activationId: string;
35
+ }
36
+ export interface RuleHostEvaluationReport {
37
+ readonly liveDecision: RuleHostResult | undefined;
38
+ readonly shadowDecisions: readonly RuleHostObservedDecision[];
39
+ }
33
40
  export declare class RuleHost {
34
41
  private readonly stateDir;
35
- private readonly logger;
42
+ private logger;
36
43
  private readonly workspaceDir;
37
44
  private readonly implementationSources;
45
+ private activationFingerprint;
46
+ private cachedImplementations;
47
+ private sqliteConnection;
38
48
  constructor(stateDir: string, logger?: RuleHostLogger, options?: RuleHostOptions);
49
+ /**
50
+ * Update the logger sink on a cached RuleHost instance.
51
+ *
52
+ * WorkspaceContext caches the RuleHost singleton, but each gate call may
53
+ * pass a request-level logger. Without this update, warn/unhealthy logs
54
+ * would forever go to the first logger sink, making the new path hard to
55
+ * debug.
56
+ */
57
+ updateLogger(logger: RuleHostLogger): void;
39
58
  /**
40
59
  * Evaluate the input against all active code implementations.
41
60
  *
@@ -46,6 +65,8 @@ export declare class RuleHost {
46
65
  * - { decision: 'requireApproval', ... } when any implementation returns requireApproval
47
66
  */
48
67
  evaluate(input: RuleHostInput): RuleHostResult | undefined;
68
+ dispose(): void;
69
+ evaluateDetailed(input: RuleHostInput): RuleHostEvaluationReport;
49
70
  /**
50
71
  * Load active code implementations from the SQLite activations table.
51
72
  *
@@ -48,11 +48,25 @@ export class RuleHost {
48
48
  logger;
49
49
  workspaceDir;
50
50
  implementationSources = new Map();
51
+ activationFingerprint = null;
52
+ cachedImplementations = [];
53
+ sqliteConnection = null;
51
54
  constructor(stateDir, logger = console, options) {
52
55
  this.stateDir = stateDir;
53
56
  this.logger = logger;
54
57
  this.workspaceDir = options?.workspaceDir ?? null;
55
58
  }
59
+ /**
60
+ * Update the logger sink on a cached RuleHost instance.
61
+ *
62
+ * WorkspaceContext caches the RuleHost singleton, but each gate call may
63
+ * pass a request-level logger. Without this update, warn/unhealthy logs
64
+ * would forever go to the first logger sink, making the new path hard to
65
+ * debug.
66
+ */
67
+ updateLogger(logger) {
68
+ this.logger = logger;
69
+ }
56
70
  /**
57
71
  * Evaluate the input against all active code implementations.
58
72
  *
@@ -63,9 +77,36 @@ export class RuleHost {
63
77
  * - { decision: 'requireApproval', ... } when any implementation returns requireApproval
64
78
  */
65
79
  evaluate(input) {
80
+ return this.evaluateDetailed(input).liveDecision;
81
+ }
82
+ dispose() {
83
+ this.sqliteConnection?.close();
84
+ this.sqliteConnection = null;
85
+ this.cachedImplementations = [];
86
+ this.activationFingerprint = null;
87
+ this.implementationSources.clear();
88
+ }
89
+ evaluateDetailed(input) {
66
90
  try {
67
- const activeImpls = this._loadActiveCodeImplementations();
68
- return mergeDecisions(activeImpls, input, {
91
+ const activeImpls = this._loadActiveCodeImplementations(input.context?.version === 2);
92
+ const liveImpls = activeImpls.filter((impl) => impl.activationMode === 'live');
93
+ const shadowImpls = activeImpls.filter((impl) => impl.activationMode === 'shadow');
94
+ const shadowDecisions = [];
95
+ for (const impl of shadowImpls) {
96
+ try {
97
+ const result = impl.evaluate(input);
98
+ shadowDecisions.push({ ...result, activationId: impl.activationId });
99
+ }
100
+ catch (evalError) {
101
+ const reason = `evaluation failed: ${String(evalError)}`;
102
+ this.logger.warn?.(`[RuleHost] Shadow implementation ${impl.implId} ${reason}`);
103
+ const source = this.implementationSources.get(impl.implId);
104
+ if (source) {
105
+ this._recordUnhealthy(source.activationId, source.artifactId, source.ruleId, reason, 'Fix the RuleCode runtime error or return shape, then re-activate the rule');
106
+ }
107
+ }
108
+ }
109
+ const liveDecision = mergeDecisions(liveImpls, input, {
69
110
  warn: this.logger.warn,
70
111
  onImplementationUnhealthy: (impl, reason) => {
71
112
  const source = this.implementationSources.get(impl.implId);
@@ -76,11 +117,12 @@ export class RuleHost {
76
117
  this._recordUnhealthy(source.activationId, source.artifactId, source.ruleId, reason, 'Fix the RuleCode runtime error or return shape, then re-activate the rule');
77
118
  },
78
119
  });
120
+ return { liveDecision, shadowDecisions };
79
121
  }
80
122
  catch (hostError) {
81
123
  // Conservative degradation: log and return undefined (D-08)
82
124
  this.logger.warn?.(`[RuleHost] Host evaluation failed, degrading conservatively: ${String(hostError)}`);
83
- return undefined;
125
+ return { liveDecision: undefined, shadowDecisions: [] };
84
126
  }
85
127
  }
86
128
  /**
@@ -93,12 +135,12 @@ export class RuleHost {
93
135
  * Source: activations table (code_tool_hook channel, deactivated_at IS NULL)
94
136
  * → JOIN pi_artifacts for content_json → extract implementationCode → compile
95
137
  */
96
- _loadActiveCodeImplementations() {
138
+ _loadActiveCodeImplementations(supportsContextV2) {
97
139
  if (!this.workspaceDir) {
98
140
  return [];
99
141
  }
100
142
  try {
101
- return this._loadFromActivationsTable(this.workspaceDir);
143
+ return this._loadFromActivationsTable(this.workspaceDir, supportsContextV2);
102
144
  }
103
145
  catch (activationError) {
104
146
  this.logger.warn?.(`[RuleHost] Failed to load code_tool_hook activations: ${String(activationError)}`);
@@ -120,13 +162,13 @@ export class RuleHost {
120
162
  *
121
163
  * All data from SQLite is treated as unknown and validated before use.
122
164
  */
123
- _loadFromActivationsTable(workspaceDir) {
124
- this.implementationSources.clear();
125
- const sqliteConn = new SqliteConnection(workspaceDir);
126
- try {
165
+ _loadFromActivationsTable(workspaceDir, supportsContextV2) {
166
+ const sqliteConn = this.sqliteConnection ?? new SqliteConnection(workspaceDir);
167
+ this.sqliteConnection = sqliteConn;
168
+ {
127
169
  const db = sqliteConn.getDb();
128
170
  const rows = db.prepare(`
129
- SELECT a.activation_id, a.artifact_id, a.target_ref,
171
+ SELECT a.activation_id, a.artifact_id, a.target_ref, a.action,
130
172
  p.content_json, p.source_rule_id
131
173
  FROM activations a
132
174
  JOIN pi_artifacts p ON a.artifact_id = p.artifact_id
@@ -137,6 +179,20 @@ export class RuleHost {
137
179
  this.logger.warn?.('[RuleHost] Activations table query returned non-array, skipping');
138
180
  return [];
139
181
  }
182
+ const fingerprintParts = [supportsContextV2 ? 'context-v2' : 'context-v1'];
183
+ for (const row of rows) {
184
+ if (!row || typeof row !== 'object' || Array.isArray(row))
185
+ continue;
186
+ const record = row;
187
+ fingerprintParts.push([
188
+ record['activation_id'], record['artifact_id'], record['target_ref'], record['action'], record['content_json'], record['source_rule_id'],
189
+ ].map((value) => typeof value === 'string' ? value : '').join('\u0001'));
190
+ }
191
+ const fingerprint = fingerprintParts.join('\u0002');
192
+ if (fingerprint === this.activationFingerprint) {
193
+ return [...this.cachedImplementations];
194
+ }
195
+ this.implementationSources.clear();
140
196
  // PRI-436: Group active rows by target_ref to detect duplicates.
141
197
  // At most one active activation per rule (target_ref) is allowed.
142
198
  // Duplicate groups are skipped entirely (zero executions) and emit
@@ -199,10 +255,35 @@ export class RuleHost {
199
255
  const artifactId = typeof r['artifact_id'] === 'string' ? r['artifact_id'] : '';
200
256
  const contentJson = typeof r['content_json'] === 'string' ? r['content_json'] : '';
201
257
  const sourceRuleId = typeof r['source_rule_id'] === 'string' ? r['source_rule_id'] : null;
258
+ const action = typeof r['action'] === 'string' ? r['action'] : '';
202
259
  if (!activationId || !artifactId || !contentJson) {
203
260
  this.logger.warn?.(`[RuleHost] Activation row missing required fields, skipping`);
204
261
  continue;
205
262
  }
263
+ const activationMode = action === 'code_tool_hook_shadow_activate'
264
+ ? 'shadow'
265
+ : action === 'code_tool_hook_live_activate'
266
+ ? 'live'
267
+ : null;
268
+ if (!activationMode) {
269
+ this.logger.warn?.(`[RuleHost] Activation ${activationId}: unsupported action ${action || '(missing)'}, skipping. ` +
270
+ 'nextAction=deactivate and recreate the activation through RuleHostWriter');
271
+ continue;
272
+ }
273
+ if (activationMode === 'shadow') {
274
+ // CodeRabbit PR #1121: shadow activations are observation-only and do
275
+ // NOT enter mergeDecisions (no block / requireApproval). Historical
276
+ // owner-approved records persisted with action=code_tool_hook_shadow_activate
277
+ // (before RuleHostWriter was fixed to write code_tool_hook_live_activate)
278
+ // silently lost execution after the dual-mode change. Surface a
279
+ // structured reason + nextAction so the degradation is observable
280
+ // (rc-9-no-silent-fallback), not silent. Fires once per fingerprint
281
+ // change (cached), not per evaluation.
282
+ this.logger.warn?.(`[RuleHost] Activation ${activationId}: loaded in shadow (observation-only) mode; ` +
283
+ 'it will NOT block or require approval (shadowDecisions only). ' +
284
+ `nextAction=run \`pd runtime activation promote --activation-id ${activationId} --confirm\` to enable live blocking, ` +
285
+ 'or leave as-is for shadow observation.');
286
+ }
206
287
  try {
207
288
  const content = JSON.parse(contentJson);
208
289
  if (!content || typeof content !== 'object' || Array.isArray(content)) {
@@ -210,6 +291,18 @@ export class RuleHost {
210
291
  continue;
211
292
  }
212
293
  const contentObj = content;
294
+ if (Object.hasOwn(contentObj, 'requiresContextVersion')) {
295
+ if (contentObj['requiresContextVersion'] !== 2) {
296
+ this.logger.warn?.(`[RuleHost] Activation ${activationId}: unsupported context version; skipping. ` +
297
+ 'nextAction=regenerate the rule artifact with requiresContextVersion: 2 or omit the field for v1');
298
+ continue;
299
+ }
300
+ if (!supportsContextV2) {
301
+ this.logger.warn?.(`[RuleHost] Activation ${activationId}: suspended because rulecode_context_v2 is disabled or unavailable; skipping. ` +
302
+ 'nextAction=enable rulecode_context_v2 with valid config or deactivate this activation');
303
+ continue;
304
+ }
305
+ }
213
306
  const implementationCode = contentObj['implementationCode'];
214
307
  if (typeof implementationCode !== 'string' || implementationCode.length === 0) {
215
308
  this.logger.warn?.(`[RuleHost] Activation ${activationId}: no implementationCode in artifact, skipping`);
@@ -244,6 +337,8 @@ export class RuleHost {
244
337
  implId,
245
338
  ruleId,
246
339
  meta,
340
+ activationId,
341
+ activationMode,
247
342
  evaluate: (input) => {
248
343
  const frozenHelpers = createRuleHostHelpers(input);
249
344
  // PRI-437: Execute inside vm context with timeout boundary.
@@ -269,16 +364,10 @@ export class RuleHost {
269
364
  this._recordUnhealthy(activationId, artifactId, sourceRuleId ?? artifactId, reason, 'Fix the RuleCode syntax/compilation error, then re-activate the rule');
270
365
  }
271
366
  }
367
+ this.activationFingerprint = fingerprint;
368
+ this.cachedImplementations = loaded;
272
369
  return loaded;
273
370
  }
274
- finally {
275
- try {
276
- sqliteConn.close();
277
- }
278
- catch {
279
- // best-effort cleanup
280
- }
281
- }
282
371
  }
283
372
  /**
284
373
  * PRI-437: Record an unhealthy activation state to EventLog.
@@ -8,6 +8,8 @@ import type { TrajectoryDatabase } from './trajectory.js';
8
8
  import { PrincipleLifecycleService } from './principle-internalization/principle-lifecycle-service.js';
9
9
  import { type PrincipleSubtree, type LedgerPrinciple, type PrincipleValueMetrics } from './principle-tree-ledger.js';
10
10
  import type { Principle as ActivePrinciple } from './evolution-types.js';
11
+ import { RuleHost } from './rule-host.js';
12
+ import type { RuleHostLogger } from './rule-host.js';
11
13
  interface PrincipleTreeLedgerAccessor {
12
14
  getPrincipleSubtree(_principleId: string): PrincipleSubtree | undefined;
13
15
  updatePrinciple(_principleId: string, updates: Partial<LedgerPrinciple>): LedgerPrinciple;
@@ -30,6 +32,7 @@ export declare class WorkspaceContext {
30
32
  private _trajectory?;
31
33
  private _principleTreeLedger?;
32
34
  private _principleLifecycle?;
35
+ private _ruleHost?;
33
36
  private constructor();
34
37
  /**
35
38
  * Governance configuration for this workspace.
@@ -55,6 +58,7 @@ export declare class WorkspaceContext {
55
58
  * Trajectory database for analytics and sample curation.
56
59
  */
57
60
  get trajectory(): TrajectoryDatabase;
61
+ getRuleHost(logger: RuleHostLogger): RuleHost;
58
62
  /**
59
63
  * Locked ledger access for principle tree reads and metric writes in this workspace.
60
64
  */
@@ -9,6 +9,7 @@ import { EvolutionReducerImpl } from './evolution-reducer.js';
9
9
  import { TrajectoryRegistry } from './trajectory.js';
10
10
  import { PrincipleLifecycleService } from './principle-internalization/principle-lifecycle-service.js';
11
11
  import { getPrincipleSubtree, updatePrinciple, updatePrincipleValueMetrics, } from './principle-tree-ledger.js';
12
+ import { RuleHost } from './rule-host.js';
12
13
  /**
13
14
  * WorkspaceContext - Centralized management of workspace-specific paths and services.
14
15
  * Implements a cached singleton pattern per workspace directory.
@@ -26,6 +27,7 @@ export class WorkspaceContext {
26
27
  _trajectory;
27
28
  _principleTreeLedger;
28
29
  _principleLifecycle;
30
+ _ruleHost;
29
31
  constructor(workspaceDir, stateDir) {
30
32
  this.workspaceDir = workspaceDir;
31
33
  this.stateDir = stateDir;
@@ -84,6 +86,15 @@ export class WorkspaceContext {
84
86
  }
85
87
  return this._trajectory;
86
88
  }
89
+ getRuleHost(logger) {
90
+ if (!this._ruleHost) {
91
+ this._ruleHost = new RuleHost(this.stateDir, logger, { workspaceDir: this.workspaceDir });
92
+ }
93
+ else {
94
+ this._ruleHost.updateLogger(logger);
95
+ }
96
+ return this._ruleHost;
97
+ }
87
98
  /**
88
99
  * Locked ledger access for principle tree reads and metric writes in this workspace.
89
100
  */
@@ -214,6 +225,8 @@ export class WorkspaceContext {
214
225
  * Resets internal caches for services and paths.
215
226
  */
216
227
  invalidate() {
228
+ this._ruleHost?.dispose();
229
+ this._ruleHost = undefined;
217
230
  this._config = undefined;
218
231
  this._eventLog = undefined;
219
232
  this._dictionary = undefined;
@@ -8,11 +8,9 @@
8
8
  * 1. Early Return: Skip if not write/bash/agent tool or no workspace
9
9
  * 2. Rule Host: Dynamic principle-based evaluation (sole gate)
10
10
  */
11
- import { normalizePath } from '../utils/io.js';
12
11
  import { WorkspaceContext } from '../core/workspace-context.js';
13
12
  import { recordGateBlockAndReturn } from './gate-block-helper.js';
14
- import { RuleHost } from '../core/rule-host.js';
15
- import { validateCorrectionProposal, validateProposedPathBounds, computeFeatureFlagsFromConfig, UNAVAILABLE_RULE_CONTEXT } from '@principles/core/runtime-v2';
13
+ import { buildRuleHostAction, validateCorrectionProposal, validateProposedPathBounds, computeFeatureFlagsFromConfig, UNAVAILABLE_RULE_CONTEXT } from '@principles/core/runtime-v2';
16
14
  import { AGENT_TOOLS, BASH_TOOLS_SET, WRITE_TOOLS } from '../constants/tools.js';
17
15
  import { getSession, hasRecentThinking } from '../core/session-tracker.js';
18
16
  import { getEvolutionEngine } from '../core/evolution-engine.js';
@@ -30,41 +28,27 @@ export function handleBeforeToolCall(event, ctx) {
30
28
  return;
31
29
  }
32
30
  const wctx = WorkspaceContext.fromHookContext(ctx);
33
- // 2. Resolve the target file path
34
- let filePath = event.params?.file_path || event.params?.path || event.params?.file || event.params?.target;
35
- // Heuristic for bash mutation detection
36
- if (isBash && !filePath) {
37
- const command = String(event.params?.command || event.params?.args || '');
38
- const mutationMatch = /(?:>|>>|sed\s+-i|rm|mv|mkdir|touch|cp)\s+(?:-[a-zA-Z]+\s+)*([^\s;&|<>]+)/.exec(command);
39
- if (mutationMatch) {
40
- filePath = mutationMatch[1];
41
- }
42
- else {
43
- // Bash command without a clear file target — let it through to Rule Host
44
- filePath = command;
45
- }
46
- }
47
- // Write tools without a file path must still go through RuleHost evaluation.
48
- // Use a synthetic path so RuleHost can evaluate and potentially block.
49
- if (!filePath && isWriteTool) {
50
- filePath = `<tool:${event.toolName}>`;
51
- }
52
- if (typeof filePath !== 'string')
31
+ // 2. Use the same action builder as Golden Trace replay. This is the single
32
+ // path extraction + normalization contract for production and evaluation.
33
+ const action = buildRuleHostAction(event.toolName, event.params ?? {}, ctx.workspaceDir, {
34
+ isBashTool: isBash,
35
+ isWriteTool,
36
+ });
37
+ const relPath = action.normalizedPath;
38
+ // buildRuleHostAction returns null when no path can be extracted (e.g. bash
39
+ // command with no file target and no clear mutation operator). Mirror the
40
+ // legacy guard: no path → no gate evaluation, let the tool through.
41
+ if (relPath === null)
53
42
  return;
54
- const relPath = normalizePath(filePath, ctx.workspaceDir);
55
43
  // 3. Rule Host Evaluation — sole gate
56
44
  try {
57
- const ruleHost = new RuleHost(wctx.stateDir, logger, { workspaceDir: ctx.workspaceDir });
45
+ const ruleHost = wctx.getRuleHost(logger);
58
46
  // PRI-483 Phase 4: assemble RuleContextV2 when `rulecode_context_v2` flag is ON.
59
47
  // flag OFF → undefined (v1 zero-change, no trajectory access).
60
48
  // flag ON → RuleContextV2 (available or unavailable). Never throws (ERR-024).
61
49
  const ruleContext = _buildRuleContextIfEnabled(wctx, relPath, ctx.sessionId, logger);
62
50
  const hostInput = {
63
- action: {
64
- toolName: event.toolName,
65
- normalizedPath: relPath,
66
- paramsSummary: _extractParamsSummary(event.params ?? {}),
67
- },
51
+ action,
68
52
  workspace: {
69
53
  isRiskPath: false, // Rule Host determines risk dynamically
70
54
  // DEPRECATED (PRI-286): planStatus/hasPlanFile are legacy compatibility fields.
@@ -88,7 +72,27 @@ export function handleBeforeToolCall(event, ctx) {
88
72
  },
89
73
  context: ruleContext,
90
74
  };
91
- const hostResult = ruleHost.evaluate(hostInput);
75
+ const report = typeof ruleHost.evaluateDetailed === 'function'
76
+ ? ruleHost.evaluateDetailed(hostInput)
77
+ : { liveDecision: ruleHost.evaluate(hostInput), shadowDecisions: [] };
78
+ const hostResult = report.liveDecision;
79
+ for (const shadowDecision of report.shadowDecisions) {
80
+ try {
81
+ const eventLog = EventLogService.get(wctx.stateDir, logger);
82
+ eventLog.recordRuleHostEvaluated({
83
+ toolName: event.toolName,
84
+ filePath: relPath,
85
+ matched: shadowDecision.matched,
86
+ decision: shadowDecision.decision,
87
+ ruleId: shadowDecision.ruleId,
88
+ activationId: shadowDecision.activationId,
89
+ activationMode: 'shadow',
90
+ });
91
+ }
92
+ catch (evErr) {
93
+ logger?.warn?.(`[PD_GATE] Failed to record shadow rulehost_evaluated: ${String(evErr)}`);
94
+ }
95
+ }
92
96
  // Always emit rulehost_evaluated
93
97
  try {
94
98
  const eventLog = EventLogService.get(wctx.stateDir, logger);
@@ -98,6 +102,7 @@ export function handleBeforeToolCall(event, ctx) {
98
102
  matched: hostResult?.matched ?? false,
99
103
  decision: hostResult?.decision ?? 'allow',
100
104
  ruleId: hostResult?.ruleId,
105
+ activationMode: 'live',
101
106
  });
102
107
  }
103
108
  catch (evErr) {
@@ -321,25 +326,6 @@ export function handleBeforeToolCall(event, ctx) {
321
326
  // ---------------------------------------------------------------------------
322
327
  // Private helpers
323
328
  // ---------------------------------------------------------------------------
324
- function _extractParamsSummary(params) {
325
- const summary = {};
326
- // NOTE: Do NOT redact here — this feeds into RuleHost.evaluate() which
327
- // may match against paramsSummary.command. Redaction happens at
328
- // EventLog.record() before persistence.
329
- if (params.file_path)
330
- summary.file_path = params.file_path;
331
- if (params.path)
332
- summary.path = params.path;
333
- if (params.command)
334
- summary.command = params.command;
335
- if (params.args)
336
- summary.args = params.args;
337
- if (params.old_string)
338
- summary.old_string = params.old_string;
339
- if (params.new_string)
340
- summary.new_string = params.new_string;
341
- return summary;
342
- }
343
329
  function _getCurrentGfi(sessionId) {
344
330
  if (!sessionId)
345
331
  return 0;
@@ -2,7 +2,7 @@
2
2
  "id": "principles-disciple",
3
3
  "name": "Principles Disciple",
4
4
  "description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
5
- "version": "1.167.1",
5
+ "version": "1.168.0",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.167.1",
3
+ "version": "1.168.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",