principles-disciple 1.117.0 → 1.119.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.
@@ -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.117.0",
5
+ "version": "1.119.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.117.0",
3
+ "version": "1.119.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -923,6 +923,48 @@ function installTargetDependencies() {
923
923
  }
924
924
  }
925
925
 
926
+ /**
927
+ * Verify injected workspace packages have their transitive dependencies installed.
928
+ * npm install --omit=dev in the target doesn't resolve deps of manually-copied packages,
929
+ * so transitive deps like @earendil-works/pi-agent-core may be missing after production install.
930
+ */
931
+ function verifyInjectedWorkspaceDeps() {
932
+ const corePkgDir = join(INSTALL_DIR, 'node_modules', '@principles', 'core');
933
+ if (!existsSync(corePkgDir)) return;
934
+
935
+ const corePkgPath = join(corePkgDir, 'package.json');
936
+ if (!existsSync(corePkgPath)) return;
937
+
938
+ let corePkg;
939
+ try {
940
+ corePkg = JSON.parse(readFileSync(corePkgPath, 'utf-8'));
941
+ } catch (error) {
942
+ console.error(` ❌ Failed to parse @principles/core/package.json: ${error.message}`);
943
+ process.exit(1);
944
+ }
945
+
946
+ const deps = corePkg.dependencies || {};
947
+ const missing = [];
948
+ for (const [dep, version] of Object.entries(deps)) {
949
+ if (!existsSync(join(INSTALL_DIR, 'node_modules', dep))) {
950
+ missing.push(`${dep}@${version}`);
951
+ }
952
+ }
953
+
954
+ if (missing.length === 0) return;
955
+
956
+ console.log(` ⚠️ ${missing.length} transitive dependenc${missing.length === 1 ? 'y' : 'ies'} of @principles/core missing, installing...`);
957
+ try {
958
+ execSync(`npm install ${missing.join(' ')} --no-audit --no-fund --prefer-offline`, {
959
+ cwd: INSTALL_DIR,
960
+ stdio: 'pipe'
961
+ });
962
+ console.log(' ✅ Transitive dependencies installed');
963
+ } catch (error) {
964
+ console.warn(` ⚠️ Failed to install transitive dependencies: ${error.message}`);
965
+ }
966
+ }
967
+
926
968
  /**
927
969
  * Clean stale backups.
928
970
  */
@@ -1269,6 +1311,7 @@ function main() {
1269
1311
 
1270
1312
  injectLocalWorkspacePackages();
1271
1313
  installTargetDependencies();
1314
+ verifyInjectedWorkspaceDeps();
1272
1315
  verifyPdCliShim();
1273
1316
 
1274
1317
  console.log('\n🔍 Verifying installed plugin can load native dependencies...');
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Rule Host — Constrained execution layer for active code implementations
3
3
  *
4
- * PURPOSE: Load active code implementations from the principle-tree ledger,
5
- * execute them in a constrained node:vm context, and merge their decisions.
4
+ * PURPOSE: Load active code implementations from the principle-tree ledger
5
+ * AND from the activations table (code_tool_hook channel), execute them in a
6
+ * constrained node:vm context, and merge their decisions.
6
7
  *
7
8
  * ARCHITECTURE:
8
9
  * - Constructor takes stateDir to access the principle-tree ledger
10
+ * - Optional workspaceDir enables reading code_tool_hook activations from SQLite
9
11
  * - evaluate(input) loads active code implementations and runs them
10
12
  * - Each implementation executes in an isolated vm context with minimal helpers
11
13
  * - Decision merge: block short-circuits, requireApproval collects, allow is implicit
@@ -27,6 +29,7 @@ import {
27
29
  import { loadEntrySource } from './code-implementation-storage.js';
28
30
  import { createRuleHostHelpers } from '@principles/core/runtime-v2';
29
31
  import { mergeDecisions } from '@principles/core/runtime-v2';
32
+ import { SqliteConnection } from '@principles/core/runtime-v2';
30
33
  import { loadRuleImplementationModule } from './rule-implementation-runtime.js';
31
34
  import type {
32
35
  RuleHostInput,
@@ -39,13 +42,37 @@ import type { Implementation } from '../types/principle-tree-schema.js';
39
42
  import type { RuleHostLogger } from '@principles/core/runtime-v2';
40
43
  export type { RuleHostLogger } from '@principles/core/runtime-v2';
41
44
 
45
+ export interface RuleHostOptions {
46
+ /** Workspace directory for SQLite access. When provided, RuleHost also loads code_tool_hook activations from the activations table. */
47
+ workspaceDir?: string;
48
+ }
49
+
50
+ /**
51
+ * Type guard for RuleHostMeta from untrusted module exports.
52
+ * Validates all four required string fields (EP-01: no `as` bypass at trust boundary).
53
+ */
54
+ function isRuleHostMeta(value: unknown): value is RuleHostMeta {
55
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
56
+ return false;
57
+ }
58
+ const v = value as Record<string, unknown>;
59
+ return (
60
+ typeof v['name'] === 'string' &&
61
+ typeof v['version'] === 'string' &&
62
+ typeof v['ruleId'] === 'string' &&
63
+ typeof v['coversCondition'] === 'string'
64
+ );
65
+ }
66
+
42
67
  export class RuleHost {
43
68
  private readonly stateDir: string;
44
69
  private readonly logger: RuleHostLogger;
70
+ private readonly workspaceDir: string | null;
45
71
 
46
- constructor(stateDir: string, logger: RuleHostLogger = console) {
72
+ constructor(stateDir: string, logger: RuleHostLogger = console, options?: RuleHostOptions) {
47
73
  this.stateDir = stateDir;
48
74
  this.logger = logger;
75
+ this.workspaceDir = options?.workspaceDir ?? null;
49
76
  }
50
77
 
51
78
  /**
@@ -72,11 +99,21 @@ export class RuleHost {
72
99
  }
73
100
 
74
101
  /**
75
- * Load active code implementations from the ledger.
76
- * Filters by type=code and lifecycleState=active, then attempts to
77
- * compile each implementation's code asset via node:vm.
102
+ * Load active code implementations from the ledger AND the activations table.
103
+ *
104
+ * Sources (merged, deduplicated by implId):
105
+ * 1. principle-tree-ledger.json: implementations with lifecycleState='active' and type='code'
106
+ * 2. activations table: code_tool_hook channel activations (when workspaceDir is provided)
107
+ *
108
+ * This bridges the gap between RuleHostWriter (which records activation metadata in SQLite)
109
+ * and RuleHost enforcement (which previously only read from the ledger JSON file).
110
+ * See BUG-001 / ERR-011 / ERR-035.
78
111
  */
79
112
  private _loadActiveCodeImplementations(): LoadedImplementation[] {
113
+ const loaded: LoadedImplementation[] = [];
114
+ const seenImplIds = new Set<string>();
115
+
116
+ // Source 1: principle-tree-ledger.json (existing path)
80
117
  try {
81
118
  const activeAllTypes = listImplementationsByLifecycleState(
82
119
  this.stateDir,
@@ -86,17 +123,12 @@ export class RuleHost {
86
123
  // Filter to code-type implementations only
87
124
  const codeImpls = activeAllTypes.filter((impl) => impl.type === 'code');
88
125
 
89
- if (codeImpls.length === 0) {
90
- return [];
91
- }
92
-
93
- const loaded: LoadedImplementation[] = [];
94
-
95
126
  for (const impl of codeImpls) {
96
127
  try {
97
128
  const loadedImpl = this._loadSingleImplementation(impl);
98
- if (loadedImpl) {
129
+ if (loadedImpl && !seenImplIds.has(loadedImpl.implId)) {
99
130
  loaded.push(loadedImpl);
131
+ seenImplIds.add(loadedImpl.implId);
100
132
  }
101
133
  } catch (loadError: unknown) {
102
134
  // Individual load failure: log and skip
@@ -105,14 +137,157 @@ export class RuleHost {
105
137
  );
106
138
  }
107
139
  }
108
-
109
- return loaded;
110
140
  } catch (ledgerError: unknown) {
111
- // Ledger access failure: log and return empty
141
+ // Ledger access failure: log and continue to activations table
112
142
  this.logger.warn?.(
113
143
  `[RuleHost] Failed to access ledger: ${String(ledgerError)}`
114
144
  );
115
- return [];
145
+ }
146
+
147
+ // Source 2: activations table (code_tool_hook channel) — bridges BUG-001
148
+ if (this.workspaceDir) {
149
+ try {
150
+ const activationImpls = this._loadFromActivationsTable(this.workspaceDir);
151
+ for (const impl of activationImpls) {
152
+ if (!seenImplIds.has(impl.implId)) {
153
+ loaded.push(impl);
154
+ seenImplIds.add(impl.implId);
155
+ }
156
+ }
157
+ } catch (activationError: unknown) {
158
+ // Activations table access failure: log and continue with ledger-only results
159
+ this.logger.warn?.(
160
+ `[RuleHost] Failed to load code_tool_hook activations: ${String(activationError)}`
161
+ );
162
+ }
163
+ }
164
+
165
+ return loaded;
166
+ }
167
+
168
+ /**
169
+ * Load active code implementations from the activations table (code_tool_hook channel).
170
+ *
171
+ * For each activation record:
172
+ * 1. Query the pi_artifacts table for the artifact content
173
+ * 2. Parse content_json to extract implementationCode
174
+ * 3. Compile via loadRuleImplementationModule (same vm isolation as ledger path)
175
+ *
176
+ * This mirrors the PromptActivationReader pattern for SQLite access.
177
+ */
178
+ private _loadFromActivationsTable(workspaceDir: string): LoadedImplementation[] {
179
+ const sqliteConn = new SqliteConnection(workspaceDir);
180
+ try {
181
+ const db = sqliteConn.getDb();
182
+ const rows = db.prepare(`
183
+ SELECT a.activation_id, a.artifact_id, a.target_ref,
184
+ p.content_json, p.source_rule_id
185
+ FROM activations a
186
+ JOIN pi_artifacts p ON a.artifact_id = p.artifact_id
187
+ WHERE a.channel = 'code_tool_hook' AND a.deactivated_at IS NULL
188
+ ORDER BY a.activated_at ASC
189
+ `).all() as unknown;
190
+
191
+ if (!Array.isArray(rows)) {
192
+ this.logger.warn?.(
193
+ '[RuleHost] Activations table query returned non-array, skipping'
194
+ );
195
+ return [];
196
+ }
197
+
198
+ const loaded: LoadedImplementation[] = [];
199
+
200
+ for (const row of rows) {
201
+ if (!row || typeof row !== 'object') {
202
+ continue;
203
+ }
204
+ const r = row as Record<string, unknown>;
205
+ const activationId = typeof r['activation_id'] === 'string' ? r['activation_id'] : '';
206
+ const artifactId = typeof r['artifact_id'] === 'string' ? r['artifact_id'] : '';
207
+ const contentJson = typeof r['content_json'] === 'string' ? r['content_json'] : '';
208
+ const sourceRuleId = typeof r['source_rule_id'] === 'string' ? r['source_rule_id'] : null;
209
+
210
+ if (!activationId || !artifactId || !contentJson) {
211
+ this.logger.warn?.(
212
+ `[RuleHost] Activation row missing required fields, skipping`
213
+ );
214
+ continue;
215
+ }
216
+ try {
217
+ const content = JSON.parse(contentJson) as unknown;
218
+ if (!content || typeof content !== 'object' || Array.isArray(content)) {
219
+ this.logger.warn?.(
220
+ `[RuleHost] Activation ${activationId}: content_json is not an object, skipping`
221
+ );
222
+ continue;
223
+ }
224
+
225
+ const contentObj = content as Record<string, unknown>;
226
+ const implementationCode = contentObj['implementationCode'];
227
+ if (typeof implementationCode !== 'string' || implementationCode.length === 0) {
228
+ this.logger.warn?.(
229
+ `[RuleHost] Activation ${activationId}: no implementationCode in artifact, skipping`
230
+ );
231
+ continue;
232
+ }
233
+
234
+ const ruleId = typeof contentObj['ruleId'] === 'string'
235
+ ? contentObj['ruleId']
236
+ : (sourceRuleId ?? artifactId);
237
+
238
+ const implId = `act-impl-${activationId}`;
239
+ const moduleExports = loadRuleImplementationModule(implementationCode, implId);
240
+
241
+ if (!moduleExports || typeof moduleExports.evaluate !== 'function') {
242
+ this.logger.warn?.(
243
+ `[RuleHost] Activation ${activationId}: compiled module has no evaluate function, skipping`
244
+ );
245
+ continue;
246
+ }
247
+
248
+ const fallbackMeta: RuleHostMeta = {
249
+ name: implId,
250
+ version: '1',
251
+ ruleId,
252
+ coversCondition: 'all',
253
+ };
254
+ const meta: RuleHostMeta = isRuleHostMeta(moduleExports.meta)
255
+ ? moduleExports.meta
256
+ : fallbackMeta;
257
+
258
+ const rawEvaluate = moduleExports.evaluate as (
259
+ _input: RuleHostInput,
260
+ _helpers: ReturnType<typeof createRuleHostHelpers>
261
+ ) => RuleHostResult;
262
+
263
+ loaded.push({
264
+ implId,
265
+ ruleId,
266
+ meta,
267
+ evaluate: (input: RuleHostInput): RuleHostResult => {
268
+ const frozenHelpers = createRuleHostHelpers(input);
269
+ const result = rawEvaluate(input, frozenHelpers);
270
+ if (result.matched && (result.decision === 'block' || result.decision === 'requireApproval')) {
271
+ result.ruleId = ruleId;
272
+ result.principleId = meta.ruleId ?? ruleId;
273
+ }
274
+ return result;
275
+ },
276
+ });
277
+ } catch (loadError: unknown) {
278
+ this.logger.warn?.(
279
+ `[RuleHost] Failed to load activation ${activationId}: ${String(loadError)}`
280
+ );
281
+ }
282
+ }
283
+
284
+ return loaded;
285
+ } finally {
286
+ try {
287
+ sqliteConn.close();
288
+ } catch {
289
+ // best-effort cleanup
290
+ }
116
291
  }
117
292
  }
118
293
 
package/src/hooks/gate.ts CHANGED
@@ -67,7 +67,7 @@ export function handleBeforeToolCall(
67
67
 
68
68
  // 3. Rule Host Evaluation — sole gate
69
69
  try {
70
- const ruleHost = new RuleHost(wctx.stateDir, logger);
70
+ const ruleHost = new RuleHost(wctx.stateDir, logger, { workspaceDir: ctx.workspaceDir });
71
71
  const hostInput: RuleHostInput = {
72
72
  action: {
73
73
  toolName: event.toolName,
@@ -262,4 +262,166 @@ describe('TrajectoryDatabase', () => {
262
262
  expect(secondStats.painEvents).toBe(1);
263
263
  reopened.dispose();
264
264
  });
265
+
266
+ describe('recordPainEvent with canonical_pain_id (PRI-406)', () => {
267
+ it('inserts pain event without canonical_pain_id successfully', () => {
268
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-trajectory-'));
269
+ const db = new TrajectoryDatabase({ workspaceDir });
270
+
271
+ const id = db.recordPainEvent({
272
+ sessionId: 's1',
273
+ source: 'test',
274
+ score: 50,
275
+ reason: 'test reason',
276
+ origin: 'test',
277
+ });
278
+
279
+ expect(id).toBeGreaterThan(0);
280
+ db.dispose();
281
+ });
282
+
283
+ it('inserts pain event with canonical_pain_id successfully', () => {
284
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-trajectory-'));
285
+ const db = new TrajectoryDatabase({ workspaceDir });
286
+
287
+ const id = db.recordPainEvent({
288
+ sessionId: 's1',
289
+ source: 'test',
290
+ score: 50,
291
+ reason: 'test reason',
292
+ origin: 'test',
293
+ canonicalPainId: 'pain-canonical-001',
294
+ });
295
+
296
+ expect(id).toBeGreaterThan(0);
297
+ db.dispose();
298
+ });
299
+
300
+ it('handles UNIQUE constraint violation on canonical_pain_id by updating instead of throwing', () => {
301
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-trajectory-'));
302
+ const db = new TrajectoryDatabase({ workspaceDir });
303
+
304
+ const canonicalId = 'pain-canonical-duplicate';
305
+
306
+ const id1 = db.recordPainEvent({
307
+ sessionId: 's1',
308
+ source: 'test',
309
+ score: 50,
310
+ reason: 'first',
311
+ origin: 'test',
312
+ canonicalPainId: canonicalId,
313
+ });
314
+ expect(id1).toBeGreaterThan(0);
315
+
316
+ const id2 = db.recordPainEvent({
317
+ sessionId: 's1',
318
+ source: 'test',
319
+ score: 60,
320
+ reason: 'second',
321
+ origin: 'test',
322
+ canonicalPainId: canonicalId,
323
+ });
324
+
325
+ expect(id2).toBe(id1);
326
+
327
+ db.dispose();
328
+ });
329
+
330
+ it('updates runtime_task_id when canonical_pain_id conflict occurs', () => {
331
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-trajectory-'));
332
+ const db = new TrajectoryDatabase({ workspaceDir });
333
+
334
+ const canonicalId = 'pain-canonical-update-rtid';
335
+
336
+ db.recordPainEvent({
337
+ sessionId: 's1',
338
+ source: 'test',
339
+ score: 50,
340
+ reason: 'no runtime task',
341
+ origin: 'test',
342
+ canonicalPainId: canonicalId,
343
+ });
344
+
345
+ db.recordPainEvent({
346
+ sessionId: 's1',
347
+ source: 'test',
348
+ score: 60,
349
+ reason: 'with runtime task',
350
+ origin: 'test',
351
+ canonicalPainId: canonicalId,
352
+ runtimeTaskId: 'task-123',
353
+ });
354
+
355
+ const queryResult = (db as any).db.prepare(
356
+ 'SELECT runtime_task_id FROM pain_events WHERE canonical_pain_id = ?'
357
+ ).get(canonicalId);
358
+
359
+ expect(queryResult.runtime_task_id).toBe('task-123');
360
+
361
+ db.dispose();
362
+ });
363
+
364
+ it('does not overwrite existing runtime_task_id when new one is null', () => {
365
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-trajectory-'));
366
+ const db = new TrajectoryDatabase({ workspaceDir });
367
+
368
+ const canonicalId = 'pain-canonical-coalesce';
369
+
370
+ db.recordPainEvent({
371
+ sessionId: 's1',
372
+ source: 'test',
373
+ score: 50,
374
+ reason: 'with runtime task',
375
+ origin: 'test',
376
+ canonicalPainId: canonicalId,
377
+ runtimeTaskId: 'original-task',
378
+ });
379
+
380
+ db.recordPainEvent({
381
+ sessionId: 's1',
382
+ source: 'test',
383
+ score: 60,
384
+ reason: 'without runtime task',
385
+ origin: 'test',
386
+ canonicalPainId: canonicalId,
387
+ });
388
+
389
+ const queryResult = (db as any).db.prepare(
390
+ 'SELECT runtime_task_id FROM pain_events WHERE canonical_pain_id = ?'
391
+ ).get(canonicalId);
392
+
393
+ expect(queryResult.runtime_task_id).toBe('original-task');
394
+
395
+ db.dispose();
396
+ });
397
+
398
+ it('throws for non-canonical_pain_id UNIQUE constraint violations', () => {
399
+ workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-trajectory-'));
400
+ const db = new TrajectoryDatabase({ workspaceDir });
401
+
402
+ // Temporary unique index to simulate a non-canonical_pain_id conflict.
403
+ // Each test uses a fresh temp DB, so cleanup is not needed.
404
+ (db as any).db.exec('CREATE UNIQUE INDEX test_unique_source ON pain_events(source)');
405
+
406
+ db.recordPainEvent({
407
+ sessionId: 's1',
408
+ source: 'unique-source',
409
+ score: 50,
410
+ reason: 'first',
411
+ origin: 'test',
412
+ });
413
+
414
+ expect(() => {
415
+ db.recordPainEvent({
416
+ sessionId: 's1',
417
+ source: 'unique-source',
418
+ score: 60,
419
+ reason: 'second',
420
+ origin: 'test',
421
+ });
422
+ }).toThrow();
423
+
424
+ db.dispose();
425
+ });
426
+ });
265
427
  });