principles-disciple 1.61.0 → 1.62.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.61.0",
5
+ "version": "1.62.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -76,8 +76,8 @@
76
76
  }
77
77
  },
78
78
  "buildFingerprint": {
79
- "gitSha": "b300ef0761c5",
80
- "bundleMd5": "3c16a5198f3559b097d028b6e987cf5b",
81
- "builtAt": "2026-04-18T11:02:10.525Z"
79
+ "gitSha": "0d6db62f8866",
80
+ "bundleMd5": "9cf803e170837b148578f78ff3c77dbd",
81
+ "builtAt": "2026-04-18T23:04:51.214Z"
82
82
  }
83
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.61.0",
3
+ "version": "1.62.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -43,6 +43,7 @@
43
43
  "devDependencies": {
44
44
  "@testing-library/react": "^16.3.0",
45
45
  "@types/better-sqlite3": "^7.6.13",
46
+ "@types/js-yaml": "^4.0.9",
46
47
  "@types/micromatch": "^4.0.10",
47
48
  "@types/node": "^25.6.0",
48
49
  "@types/react": "^19.2.2",
@@ -70,6 +71,7 @@
70
71
  "@principles/core": "^0.1.0",
71
72
  "@sinclair/typebox": "^0.34.48",
72
73
  "better-sqlite3": "^12.9.0",
74
+ "js-yaml": "^4.1.1",
73
75
  "lucide-react": "^1.7.0",
74
76
  "micromatch": "^4.0.8",
75
77
  "react": "^19.2.0",
@@ -725,67 +725,59 @@ function restartGateway() {
725
725
  }
726
726
 
727
727
  /**
728
- * Restart Gateway on Windows using PowerShell.
728
+ * Restart Gateway on Windows via schtasks.
729
+ * Direct task trigger bypasses OpenClaw CLI's busy-port detection which can
730
+ * falsely report "still busy" due to TIME_WAIT connections on the port.
729
731
  */
730
732
  function restartGatewayWindows() {
731
733
  const logPath = join(getTempDir(), 'openclaw-auto-restart.log');
732
734
 
733
735
  try {
734
- // Step 1: Find and terminate existing gateway processes
735
- console.log(' Looking for existing gateway processes...');
736
+ // Kill existing gateway processes first (don't rely on schtasks stop)
737
+ console.log(' Stopping existing gateway processes...');
736
738
  try {
737
- // PowerShell command to find and kill openclaw gateway processes
738
- // Note: Use single quotes inside -like pattern for proper escaping
739
- const findCmd = "Get-Process -Name 'node' -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*openclaw*' } | Select-Object -ExpandProperty Id";
739
+ const findCmd = "Get-Process -Name 'node' -ErrorAction SilentlyContinue | Where-Object { \$_.CommandLine -like '*openclaw*' } | Select-Object -ExpandProperty Id";
740
740
  const pids = execSync(`powershell -NoProfile -Command "${findCmd}"`, { encoding: 'utf-8' }).trim();
741
-
742
741
  if (pids) {
743
- console.log(` Terminating existing gateway process(es): ${pids.replace(/\n/g, ', ')}...`);
744
- // Kill by PID
745
742
  const pidList = pids.split('\n').filter(p => p.trim());
746
743
  for (const pid of pidList) {
747
- try {
748
- execSync(`taskkill /PID ${pid.trim()} /F`, { stdio: 'pipe' });
749
- } catch { /* ignore if process already gone */ }
744
+ try { execSync(`taskkill /PID ${pid.trim()} /F`, { stdio: 'pipe' }); } catch { /* ignore */ }
750
745
  }
751
- // Wait a moment for process to terminate
752
- execSync('timeout /t 3 /nobreak > nul', { shell: true, stdio: 'ignore' });
746
+ // Wait for graceful shutdown
747
+ execSync('timeout /t 2 /nobreak > nul', { shell: true, stdio: 'ignore' });
753
748
  }
754
749
  } catch { /* no existing processes */ }
755
750
 
756
- // Step 2: Start new gateway process in background
757
- console.log(` Starting new gateway (logs: ${logPath})...`);
751
+ // Trigger via schtasks reliable, avoids CLI busy-port misdetection
752
+ console.log(' Starting gateway via scheduled task...');
753
+ execSync('schtasks /Run /TN "OpenClaw Gateway"', { stdio: 'inherit' });
758
754
 
759
- // Use openclaw CLI to start gateway (more reliable than direct node invocation)
760
- const gatewayCmd = join(getHomeDir(), '.openclaw', 'gateway.cmd');
761
- const startCmd = `Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${gatewayCmd}' -WindowStyle Hidden -RedirectStandardOutput '${logPath}' -RedirectStandardError '${join(getTempDir(), 'openclaw-auto-restart.err')}'`;
762
- execSync(`powershell -NoProfile -Command "${startCmd}"`, { stdio: 'inherit' });
763
- console.log('✅ Gateway restart triggered.');
755
+ // Wait for gateway to start and PD plugin to register
756
+ const deadline = Date.now() + 20000;
757
+ const pollInterval = 1000;
764
758
 
765
- // Step 3: Wait and verify
766
- setTimeout(() => {
759
+ const waitForRegistration = () => {
760
+ if (Date.now() > deadline) {
761
+ console.warn('⚠️ Gateway started but PD registration not confirmed after 20s.');
762
+ console.log(' Check logs at: ' + logPath);
763
+ return;
764
+ }
767
765
  try {
768
766
  if (existsSync(logPath)) {
769
767
  const logs = readFileSync(logPath, 'utf-8');
770
768
  if (logs.includes('Principles Disciple Plugin registered')) {
771
769
  console.log('✅ SUCCESS: Principles Disciple plugin registered successfully!');
772
- } else if (logs.includes('failed to load') || logs.includes('Error: Cannot find module')) {
773
- console.error('\n❌ CRITICAL: Gateway started but PD plugin FAILED to load!');
774
- console.error(' Check logs at: ' + logPath);
775
- process.exit(1);
776
- } else {
777
- console.warn('⚠️ Gateway started but PD registration not confirmed in recent logs.');
778
- console.log(' Check logs at: ' + logPath);
770
+ return;
779
771
  }
780
772
  }
781
- } catch (e) {
782
- console.warn(`⚠️ Post-restart verification skipped: ${e.message}`);
783
- }
784
- }, 8000);
773
+ } catch { /* ignore */ }
774
+ setTimeout(waitForRegistration, pollInterval);
775
+ };
776
+ waitForRegistration();
785
777
 
786
778
  } catch (error) {
787
- console.error(`\n❌ Failed to restart gateway: ${error.message}`);
788
- console.error(' You may need to manually restart OpenClaw Gateway.');
779
+ console.error(`\n❌ Gateway restart failed: ${error.message}`);
780
+ console.error(' You may need to manually restart: openclaw gateway start');
789
781
  process.exit(1);
790
782
  }
791
783
  }
@@ -24,6 +24,14 @@ import type {
24
24
  DiagnosticianReportEventData,
25
25
  PrincipleCandidateEventData,
26
26
  RuleEnforcedEventData,
27
+ // C: Nocturnal funnel events (PD-FUNNEL-2.3)
28
+ NocturnalDreamerCompletedEventData,
29
+ NocturnalArtifactPersistedEventData,
30
+ NocturnalCodeCandidateCreatedEventData,
31
+ // C: RuleHost funnel events (PD-FUNNEL-2.4)
32
+ RuleHostEvaluatedEventData,
33
+ RuleHostBlockedEventData,
34
+ RuleHostRequireApprovalEventData,
27
35
  } from '../types/event-types.js';
28
36
  import { createEmptyDailyStats } from '../types/event-types.js';
29
37
  import { atomicWriteFileSync } from '../utils/io.js';
@@ -215,6 +223,32 @@ export class EventLog {
215
223
  this.record('rule_enforced', 'matched', undefined, data);
216
224
  }
217
225
 
226
+ // C: Nocturnal funnel event recorders (PD-FUNNEL-2.3)
227
+ recordNocturnalDreamerCompleted(data: NocturnalDreamerCompletedEventData): void {
228
+ this.record('nocturnal_dreamer_completed', 'completed', undefined, data);
229
+ }
230
+
231
+ recordNocturnalArtifactPersisted(data: NocturnalArtifactPersistedEventData): void {
232
+ this.record('nocturnal_artifact_persisted', 'completed', undefined, data);
233
+ }
234
+
235
+ recordNocturnalCodeCandidateCreated(data: NocturnalCodeCandidateCreatedEventData): void {
236
+ this.record('nocturnal_code_candidate_created', 'created', undefined, data);
237
+ }
238
+
239
+ // C: RuleHost funnel event recorders (PD-FUNNEL-2.4)
240
+ recordRuleHostEvaluated(data: RuleHostEvaluatedEventData): void {
241
+ this.record('rulehost_evaluated', 'evaluated', undefined, data);
242
+ }
243
+
244
+ recordRuleHostBlocked(data: RuleHostBlockedEventData): void {
245
+ this.record('rulehost_blocked', 'blocked', undefined, data);
246
+ }
247
+
248
+ recordRuleHostRequireApproval(data: RuleHostRequireApprovalEventData): void {
249
+ this.record('rulehost_requireApproval', 'requireApproval', undefined, data);
250
+ }
251
+
218
252
  private record(
219
253
  type: EventType,
220
254
  category: EventCategory,
@@ -363,25 +397,57 @@ export class EventLog {
363
397
  } else if (entry.type === 'heartbeat_diagnosis') {
364
398
  stats.evolution.heartbeatsInjected++;
365
399
  } else if (entry.type === 'diagnostician_report') {
366
- const data = entry.data as unknown as DiagnosticianReportEventData;
367
400
  // Backward compat: handle old events with success:boolean and new events with category:string
368
- if ('category' in data) {
401
+ // Widen to Record<string, unknown> because DiagnosticianReportEventData requires
402
+ // category (new format) but legacy persisted events have { success: boolean }.
403
+ const raw = entry.data as unknown as Record<string, unknown>;
404
+ if (Object.prototype.hasOwnProperty.call(raw, 'category')) {
369
405
  // New format: category is 'success' | 'missing_json' | 'incomplete_fields'
370
- if (data.category === 'success' || data.category === 'incomplete_fields') {
406
+ // All three categories mean diagnosis completed and attempted to produce a report
407
+ const cat = raw['category'] as string;
408
+ if (cat === 'success' || cat === 'missing_json' || cat === 'incomplete_fields') {
371
409
  stats.evolution.diagnosticianReportsWritten++;
372
410
  }
373
- if (data.category === 'missing_json') {
411
+ if (cat === 'missing_json') {
374
412
  stats.evolution.reportsMissingJson++;
375
413
  }
376
- if (data.category === 'incomplete_fields') {
414
+ if (cat === 'incomplete_fields') {
377
415
  stats.evolution.reportsIncompleteFields++;
378
416
  }
417
+ } else if (Object.prototype.hasOwnProperty.call(raw, 'success')) {
418
+ // Legacy format: { success: boolean }
419
+ // Apply agreed default semantics: treat as 'success' if true, 'missing_json' if false
420
+ if (raw['success']) {
421
+ stats.evolution.diagnosticianReportsWritten++;
422
+ }
423
+ // Note: legacy 'false' entries are not counted in any sub-counter since
424
+ // the old system had no such breakdown; they are invisible in sub-stats.
379
425
  }
380
426
  } else if (entry.type === 'principle_candidate') {
381
427
  stats.evolution.principleCandidatesCreated++;
382
428
  } else if (entry.type === 'rule_enforced') {
383
429
  stats.evolution.rulesEnforced++;
384
430
  }
431
+ // C: Nocturnal funnel event counters (PD-FUNNEL-2.3)
432
+ else if (entry.type === 'nocturnal_dreamer_completed') {
433
+ const data = entry.data as unknown as NocturnalDreamerCompletedEventData;
434
+ stats.evolution.nocturnalDreamerCompleted++;
435
+ if (data.chainMode === 'trinity') {
436
+ stats.evolution.nocturnalTrinityCompleted++;
437
+ }
438
+ } else if (entry.type === 'nocturnal_artifact_persisted') {
439
+ stats.evolution.nocturnalArtifactPersisted++;
440
+ } else if (entry.type === 'nocturnal_code_candidate_created') {
441
+ stats.evolution.nocturnalCodeCandidateCreated++;
442
+ }
443
+ // C: RuleHost funnel event counters (PD-FUNNEL-2.4)
444
+ else if (entry.type === 'rulehost_evaluated') {
445
+ stats.evolution.rulehostEvaluated++;
446
+ } else if (entry.type === 'rulehost_blocked') {
447
+ stats.evolution.rulehostBlocked++;
448
+ } else if (entry.type === 'rulehost_requireApproval') {
449
+ stats.evolution.rulehostRequireApproval++;
450
+ }
385
451
  }
386
452
 
387
453
  private startFlushTimer(): void {
@@ -0,0 +1,170 @@
1
+ /**
2
+ * WorkflowFunnelLoader — Loads and watches workflows.yaml as the SSOT
3
+ * for WORKFLOW_FUNNELS definition table.
4
+ *
5
+ * D-01: workflows.yaml is the single source of truth (SSOT).
6
+ * D-02: workflows.yaml lives in .state/ directory per workspace.
7
+ * D-03: Developers manually maintain workflows.yaml (no auto-registration).
8
+ * D-04: Code only reads YAML, never writes it.
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import yaml from 'js-yaml';
14
+
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // Types
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * A single stage in a workflow funnel.
21
+ */
22
+ export interface WorkflowStage {
23
+ /** Stage name within the funnel (e.g., 'dreamer_completed') */
24
+ name: string;
25
+ /** Event type string (e.g., 'nocturnal_dreamer_completed') */
26
+ eventType: string;
27
+ /** Event category (e.g., 'completed', 'created', 'blocked') */
28
+ eventCategory: string;
29
+ /** Dot-path to stats field (e.g., 'evolution.nocturnalDreamerCompleted') */
30
+ statsField: string;
31
+ }
32
+
33
+ /**
34
+ * A workflow funnel definition.
35
+ */
36
+ export interface WorkflowFunnel {
37
+ workflowId: string;
38
+ stages: WorkflowStage[];
39
+ }
40
+
41
+ /**
42
+ * Root of workflows.yaml schema.
43
+ */
44
+ export interface WorkflowFunnelConfig {
45
+ version: string;
46
+ funnels: WorkflowFunnel[];
47
+ }
48
+
49
+ // ─────────────────────────────────────────────────────────────────────────────
50
+ // WorkflowFunnelLoader
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Loads and watches workflows.yaml, building an in-memory WORKFLOW_FUNNELS table.
55
+ *
56
+ * Failure semantics (per Codex review):
57
+ * - Missing file: clears in-memory funnels, uses empty Map
58
+ * - Malformed YAML: preserves last known-good config, logs warning
59
+ * - Schema-invalid YAML: same as malformed YAML
60
+ *
61
+ * Usage:
62
+ * const loader = new WorkflowFunnelLoader(stateDir);
63
+ * const funnels = loader.getAllFunnels(); // Map<string, WorkflowStage[]>
64
+ * loader.watch(); // Enable hot reload
65
+ */
66
+ export class WorkflowFunnelLoader {
67
+ /** In-memory WORKFLOW_FUNNELS table: workflowId -> stages */
68
+ private readonly funnels = new Map<string, WorkflowStage[]>();
69
+
70
+ private readonly configPath: string;
71
+
72
+ /** fs.watch() handle for cleanup */
73
+ private watchHandle?: fs.FSWatcher;
74
+
75
+ constructor(stateDir: string) {
76
+ // D-02: workflows.yaml in .state/ directory
77
+ this.configPath = path.join(stateDir, 'workflows.yaml');
78
+ this.load();
79
+ }
80
+
81
+ /**
82
+ * Load (or reload) workflows.yaml from disk.
83
+ * On parse/validation failure, preserves the last known-good config.
84
+ * On missing file, clears to empty.
85
+ */
86
+ load(): void {
87
+ if (!fs.existsSync(this.configPath)) {
88
+ this.funnels.clear();
89
+ return;
90
+ }
91
+
92
+ try {
93
+ const content = fs.readFileSync(this.configPath, 'utf-8');
94
+ // Use safe load — no arbitrary code execution
95
+ const config = yaml.load(content, { schema: yaml.DEFAULT_SCHEMA }) as WorkflowFunnelConfig;
96
+
97
+ // Validate top-level structure
98
+ if (!config || typeof config.version !== 'string' || !Array.isArray(config.funnels)) {
99
+ console.warn(`[WorkflowFunnelLoader] workflows.yaml validation failed: missing version or funnels array. Preserving last valid config.`);
100
+ return;
101
+ }
102
+
103
+ // Rebuild funnels map
104
+ const newFunnels = new Map<string, WorkflowStage[]>();
105
+ for (const funnel of config.funnels) {
106
+ if (funnel?.workflowId && typeof funnel.workflowId === 'string' && Array.isArray(funnel.stages)) {
107
+ newFunnels.set(funnel.workflowId, funnel.stages);
108
+ } else {
109
+ console.warn(`[WorkflowFunnelLoader] Skipping invalid funnel entry: missing workflowId or stages.`);
110
+ }
111
+ }
112
+
113
+ // Atomic replace: only commit if entire parse/validation succeeded
114
+ this.funnels.clear();
115
+ for (const [k, v] of newFunnels) {
116
+ this.funnels.set(k, v);
117
+ }
118
+ } catch (err) {
119
+ // Best-effort: preserve last known-good config on parse error
120
+ console.warn(`[WorkflowFunnelLoader] Failed to parse workflows.yaml: ${String(err)}. Preserving last valid config.`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Start watching workflows.yaml for changes.
126
+ * Calls load() automatically when the file changes.
127
+ */
128
+ watch(): void {
129
+ // Debounce: only re-read after file write settles (100ms)
130
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
131
+ this.watchHandle = fs.watch(this.configPath, (eventType) => {
132
+ if (eventType !== 'change') return;
133
+ if (debounceTimer) clearTimeout(debounceTimer);
134
+ debounceTimer = setTimeout(() => {
135
+ this.load();
136
+ }, 100);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Stop watching and clean up the FSWatcher.
142
+ */
143
+ dispose(): void {
144
+ if (this.watchHandle) {
145
+ this.watchHandle.close();
146
+ this.watchHandle = undefined;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Get all stages for a workflow.
152
+ */
153
+ getStages(workflowId: string): WorkflowStage[] {
154
+ return this.funnels.get(workflowId) ?? [];
155
+ }
156
+
157
+ /**
158
+ * Get the full WORKFLOW_FUNNELS table.
159
+ */
160
+ getAllFunnels(): Map<string, WorkflowStage[]> {
161
+ return new Map(this.funnels);
162
+ }
163
+
164
+ /**
165
+ * Get the config file path (for testing/debugging).
166
+ */
167
+ getConfigPath(): string {
168
+ return this.configPath;
169
+ }
170
+ }
package/src/hooks/gate.ts CHANGED
@@ -205,24 +205,41 @@ export function handleBeforeToolCall(
205
205
  };
206
206
 
207
207
  const hostResult = ruleHost.evaluate(hostInput);
208
- if (hostResult?.decision === 'block' || hostResult?.decision === 'requireApproval') {
208
+ // PD-FUNNEL-2.4: Always emit rulehost_evaluated on evaluate()
209
+ try {
210
+ const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
211
+ eventLog.recordRuleHostEvaluated({
212
+ toolName: event.toolName,
213
+ filePath: relPath,
214
+ matched: hostResult?.matched ?? false,
215
+ decision: hostResult?.decision ?? 'allow',
216
+ ruleId: hostResult?.ruleId,
217
+ });
218
+ } catch (evErr) {
219
+ logger?.warn?.(`[PD_GATE] Failed to record rulehost_evaluated: ${String(evErr)}`);
220
+ }
221
+ if (hostResult?.decision === 'block') {
209
222
  // C: Record rule_enforced event for matched rules
210
223
  try {
211
224
  const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
212
225
  eventLog.recordRuleEnforced({
213
226
  ruleId: hostResult.ruleId || 'unknown',
214
227
  principleId: hostResult.principleId || 'unknown',
215
- enforcement: hostResult.decision === 'requireApproval' ? 'requireApproval' : 'block',
228
+ enforcement: 'block',
216
229
  toolName: event.toolName,
217
230
  filePath: relPath,
218
231
  });
232
+ eventLog.recordRuleHostBlocked({
233
+ toolName: event.toolName,
234
+ filePath: relPath,
235
+ reason: hostResult.reason,
236
+ ruleId: hostResult.ruleId,
237
+ });
219
238
  } catch (evErr) {
220
- logger?.warn?.(`[PD_GATE] Failed to record rule_enforced event: ${String(evErr)}`);
239
+ logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_blocked event: ${String(evErr)}`);
221
240
  }
222
241
 
223
- const reason = hostResult.decision === 'requireApproval'
224
- ? `[Rule Host] Approval required: ${hostResult.reason}`
225
- : hostResult.reason;
242
+ const reason = hostResult.reason;
226
243
  return recordGateBlockAndReturn(wctx, {
227
244
  filePath: relPath,
228
245
  reason,
@@ -231,6 +248,26 @@ export function handleBeforeToolCall(
231
248
  blockSource: 'rule-host',
232
249
  }, logger);
233
250
  }
251
+ if (hostResult?.decision === 'requireApproval') {
252
+ try {
253
+ const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
254
+ eventLog.recordRuleEnforced({
255
+ ruleId: hostResult.ruleId || 'unknown',
256
+ principleId: hostResult.principleId || 'unknown',
257
+ enforcement: 'requireApproval',
258
+ toolName: event.toolName,
259
+ filePath: relPath,
260
+ });
261
+ eventLog.recordRuleHostRequireApproval({
262
+ toolName: event.toolName,
263
+ filePath: relPath,
264
+ reason: hostResult.reason,
265
+ ruleId: hostResult.ruleId,
266
+ });
267
+ } catch (evErr) {
268
+ logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_requireApproval event: ${String(evErr)}`);
269
+ }
270
+ }
234
271
  } catch (hostError: unknown) {
235
272
  // D-08: Conservative degradation — log and continue to Progressive Gate
236
273
  logger.warn?.(`[PD_GATE:RULE_HOST] Host evaluation failed, degrading conservatively: ${String(hostError)}`);
@@ -957,6 +957,16 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
957
957
  if (presentPhases.length < requiredPhases.length) {
958
958
  const missing = requiredPhases.filter(p => !phases[p]);
959
959
  if (logger) logger.warn(`[PD:EvolutionWorker] Report for task ${task.id} incomplete — missing phases: ${missing.join(', ')} (present: ${presentPhases.length}/${requiredPhases.length})`);
960
+ // PD-FUNNEL-1.1: Record incomplete_fields event BEFORE retry so funnel can see it.
961
+ // The phase-completeness check requeues incomplete reports with continue; without
962
+ // this record the funnel would have no signal for JSON-present-but-incomplete cases.
963
+ if (eventLog) {
964
+ eventLog.recordDiagnosticianReport({
965
+ taskId: task.id,
966
+ reportPath,
967
+ category: 'incomplete_fields',
968
+ });
969
+ }
960
970
  // Treat as retryable failure: don't mark success, let retry logic kick in
961
971
  reportParsed = false;
962
972
  // Also delete the incomplete marker so next heartbeat re-runs the diagnostician
@@ -104,6 +104,7 @@ import { registerSample } from '../core/nocturnal-dataset.js';
104
104
  import { getPrincipleState, setPrincipleState } from '../core/principle-training-state.js';
105
105
  import type { Implementation } from '../types/principle-tree-schema.js';
106
106
  import { validateNocturnalSnapshotIngress } from '../core/nocturnal-snapshot-contract.js';
107
+ import { EventLogService } from '../core/event-log.js';
107
108
 
108
109
 
109
110
  // ---------------------------------------------------------------------------
@@ -522,9 +523,20 @@ function persistCodeCandidate(
522
523
  try {
523
524
  refreshPrincipleLifecycle(workspaceDir, stateDir);
524
525
  } catch (err) {
525
-
526
526
  console.warn('[nocturnal-service] Lifecycle refresh failed after code candidate persistence:', err instanceof Error ? err.stack : err);
527
527
  }
528
+ // PD-FUNNEL-2.3: Emit nocturnal_code_candidate_created event
529
+ try {
530
+ const eventLog = EventLogService.get(stateDir, undefined);
531
+ eventLog.recordNocturnalCodeCandidateCreated({
532
+ implementationId,
533
+ artifactId,
534
+ ruleId: parsedArtificer.ruleId,
535
+ persistedPath: assetRoot,
536
+ });
537
+ } catch (evErr) {
538
+ console.warn(`[nocturnal-service] Failed to record nocturnal_code_candidate_created: ${String(evErr)}`);
539
+ }
528
540
  return {
529
541
  status: 'persisted_candidate',
530
542
  ruleResolution: {
@@ -1039,13 +1051,22 @@ export function executeNocturnalReflection(
1039
1051
  boundedAction: execResult.boundedAction,
1040
1052
  };
1041
1053
 
1042
-
1043
-
1044
1054
  let persistedPath: string;
1045
1055
  try {
1046
1056
  persistedPath = persistArtifact(workspaceDir, artifactWithBoundedAction);
1047
1057
  diagnostics.persisted = true;
1048
1058
  diagnostics.persistedPath = persistedPath;
1059
+ // PD-FUNNEL-2.3: Emit nocturnal_artifact_persisted event
1060
+ try {
1061
+ const eventLog = EventLogService.get(stateDir, undefined);
1062
+ eventLog.recordNocturnalArtifactPersisted({
1063
+ artifactId: artifactWithBoundedAction.artifactId,
1064
+ principleId: artifactWithBoundedAction.principleId,
1065
+ persistedPath,
1066
+ });
1067
+ } catch (evErr) {
1068
+ console.warn(`[nocturnal-service] Failed to record nocturnal_artifact_persisted: ${String(evErr)}`);
1069
+ }
1049
1070
  } catch (err) {
1050
1071
  void recordRunEnd(stateDir, 'failed', { reason: `persistence error: ${String(err)}` }).catch((e) => {
1051
1072
  warn(`[nocturnal-service] Failed to record run end (persistence failed): ${String(e)}`);
@@ -41,6 +41,7 @@ import * as fs from 'fs';
41
41
  import * as path from 'path';
42
42
  import { validateNocturnalSnapshotIngress } from '../../core/nocturnal-snapshot-contract.js';
43
43
  import { isSubagentRuntimeAvailable } from '../../utils/subagent-probe.js';
44
+ import { EventLogService } from '../../core/event-log.js';
44
45
 
45
46
  // ─────────────────────────────────────────────────────────────────────────────
46
47
  // NocturnalResult Type Alias
@@ -290,6 +291,21 @@ export class NocturnalWorkflowManager implements WorkflowManager {
290
291
  artifactId: result.diagnostics?.persistedPath,
291
292
  });
292
293
  this.completedWorkflows.set(workflowId, Date.now());
294
+ // PD-FUNNEL-2.3: Emit nocturnal_dreamer_completed event
295
+ const chainMode: 'trinity' | 'single-reflector' =
296
+ result.trinityTelemetry ? 'trinity' : 'single-reflector';
297
+ try {
298
+ const eventLog = EventLogService.get(this.stateDir, this.logger as unknown as import('../../openclaw-sdk.js').PluginLogger);
299
+ eventLog.recordNocturnalDreamerCompleted({
300
+ workflowId,
301
+ principleId: result.artifact?.principleId ?? 'unknown',
302
+ sessionId: result.snapshot?.sessionId ?? 'unknown',
303
+ candidateCount: result.trinityTelemetry?.candidateCount ?? 1,
304
+ chainMode,
305
+ });
306
+ } catch (evErr) {
307
+ this.logger.warn?.(`[PD:NocturnalWorkflow] Failed to record nocturnal_dreamer_completed: ${String(evErr)}`);
308
+ }
293
309
  } else {
294
310
  const reason = result.noTargetSelected ? 'no_target_selected' : 'validation_failed';
295
311
  const failuresSummary = result.validationFailures?.length > 0
@@ -23,7 +23,15 @@ export type EventType =
23
23
  | 'heartbeat_diagnosis' // Heartbeat injected diagnostician tasks
24
24
  | 'diagnostician_report' // Diagnostician completed and wrote report
25
25
  | 'principle_candidate' // Principle candidate created from report
26
- | 'rule_enforced'; // Rule enforced (matched) during tool call
26
+ | 'rule_enforced' // Rule enforced (matched) during tool call
27
+ // C: Nocturnal funnel stage events (PD-FUNNEL-2.3)
28
+ | 'nocturnal_dreamer_completed'
29
+ | 'nocturnal_artifact_persisted'
30
+ | 'nocturnal_code_candidate_created'
31
+ // C: RuleHost funnel events (PD-FUNNEL-2.4)
32
+ | 'rulehost_evaluated'
33
+ | 'rulehost_blocked'
34
+ | 'rulehost_requireApproval';
27
35
 
28
36
  export type EventCategory =
29
37
  | 'success'
@@ -42,7 +50,11 @@ export type EventCategory =
42
50
  | 'written'
43
51
  | 'injected'
44
52
  | 'created'
45
- | 'matched';
53
+ | 'matched'
54
+ // C: New categories for RuleHost funnel (PD-FUNNEL-2.4) — completed/created already exist
55
+ | 'evaluated' // Used by: rulehost_evaluated
56
+ | 'blocked' // Used by: rulehost_blocked
57
+ | 'requireApproval'; // Used by: rulehost_requireApproval
46
58
 
47
59
  /**
48
60
  * Base event structure for JSONL logging.
@@ -237,6 +249,77 @@ export interface RuleEnforcedEventData {
237
249
  filePath: string;
238
250
  }
239
251
 
252
+ // ============== Nocturnal Funnel Events (PD-FUNNEL-2.3) ==============
253
+
254
+ /**
255
+ * nocturnal_dreamer_completed — Trinity Dreamer stage completed.
256
+ * Emitted from nocturnal-workflow-manager.ts after Trinity chain success.
257
+ */
258
+ export interface NocturnalDreamerCompletedEventData {
259
+ workflowId: string;
260
+ principleId: string;
261
+ sessionId: string;
262
+ candidateCount: number;
263
+ chainMode: 'trinity' | 'single-reflector';
264
+ }
265
+
266
+ /**
267
+ * nocturnal_artifact_persisted — Artifact saved to .state/nocturnal/samples/.
268
+ * Emitted from nocturnal-service.ts persistArtifact() after atomicWriteFileSync.
269
+ */
270
+ export interface NocturnalArtifactPersistedEventData {
271
+ artifactId: string;
272
+ principleId: string;
273
+ persistedPath: string;
274
+ }
275
+
276
+ /**
277
+ * nocturnal_code_candidate_created — Rule implementation candidate persisted.
278
+ * Emitted from nocturnal-service.ts persistCodeCandidate() after successful creation.
279
+ */
280
+ export interface NocturnalCodeCandidateCreatedEventData {
281
+ implementationId: string;
282
+ artifactId: string;
283
+ ruleId: string;
284
+ persistedPath: string;
285
+ }
286
+
287
+ // ============== RuleHost Funnel Events (PD-FUNNEL-2.4) ==============
288
+
289
+ /**
290
+ * rulehost_evaluated — RuleHost.evaluate() was called.
291
+ * Emitted from gate.ts for every evaluate() call (matched or not).
292
+ */
293
+ export interface RuleHostEvaluatedEventData {
294
+ toolName: string;
295
+ filePath: string;
296
+ matched: boolean;
297
+ decision: 'allow' | 'block' | 'requireApproval';
298
+ ruleId?: string;
299
+ }
300
+
301
+ /**
302
+ * rulehost_blocked — Tool call was blocked by RuleHost.
303
+ * Emitted from gate.ts when hostResult.decision === 'block'.
304
+ */
305
+ export interface RuleHostBlockedEventData {
306
+ toolName: string;
307
+ filePath: string;
308
+ reason: string;
309
+ ruleId?: string;
310
+ }
311
+
312
+ /**
313
+ * rulehost_requireApproval — Tool call requires approval by RuleHost.
314
+ * Emitted from gate.ts when hostResult.decision === 'requireApproval'.
315
+ */
316
+ export interface RuleHostRequireApprovalEventData {
317
+ toolName: string;
318
+ filePath: string;
319
+ reason: string;
320
+ ruleId?: string;
321
+ }
322
+
240
323
  // ============== Daily Statistics ==============
241
324
 
242
325
  export interface ToolCallStats {
@@ -335,6 +418,15 @@ export interface EvolutionStats {
335
418
  reportsIncompleteFields: number;
336
419
  principleCandidatesCreated: number;
337
420
  rulesEnforced: number;
421
+ // C: Nocturnal funnel counters (PD-FUNNEL-2.3)
422
+ nocturnalDreamerCompleted: number;
423
+ nocturnalTrinityCompleted: number;
424
+ nocturnalArtifactPersisted: number;
425
+ nocturnalCodeCandidateCreated: number;
426
+ // C: RuleHost funnel counters (PD-FUNNEL-2.4)
427
+ rulehostEvaluated: number;
428
+ rulehostBlocked: number;
429
+ rulehostRequireApproval: number;
338
430
  }
339
431
 
340
432
  export interface HookStats {
@@ -501,6 +593,15 @@ export function createEmptyDailyStats(date: string): DailyStats {
501
593
  reportsIncompleteFields: 0,
502
594
  principleCandidatesCreated: 0,
503
595
  rulesEnforced: 0,
596
+ // C: Nocturnal funnel counters (PD-FUNNEL-2.3)
597
+ nocturnalDreamerCompleted: 0,
598
+ nocturnalTrinityCompleted: 0,
599
+ nocturnalArtifactPersisted: 0,
600
+ nocturnalCodeCandidateCreated: 0,
601
+ // C: RuleHost funnel counters (PD-FUNNEL-2.4)
602
+ rulehostEvaluated: 0,
603
+ rulehostBlocked: 0,
604
+ rulehostRequireApproval: 0,
504
605
  },
505
606
  hooks: {
506
607
  total: 0,
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { EventLogService, EventLog } from '../../src/core/event-log.js';
3
- import type { DailyStats, DeepReflectionEventData } from '../../src/types/event-types.js';
3
+ import type { DailyStats, DeepReflectionEventData, DiagnosticianReportEventData } from '../../src/types/event-types.js';
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
  import * as os from 'os';
@@ -252,5 +252,60 @@ describe('EventLog', () => {
252
252
  expect(stats.pain.avgScore).toBe(60); // (50+70+60)/3 = 60
253
253
  expect(stats.pain.maxScore).toBe(70);
254
254
  });
255
+
256
+ // PD-FUNNEL-1.2: Legacy backward compat — old events with { success: boolean } shape
257
+ // Stats are loaded from daily-stats.json (not re-read from JSONL), so we
258
+ // populate the stats cache directly by writing to daily-stats.json and
259
+ // creating a new EventLog instance that loads it via loadStats().
260
+ it('should count legacy success:true events in diagnosticianReportsWritten', () => {
261
+ const today = new Date().toISOString().slice(0, 10);
262
+ // Build a legacy daily-stats.json entry: old format had no category on
263
+ // diagnostician_report, and success:true meant it counted as written.
264
+ // statsFile lives at {tempDir}/logs/daily-stats.json (see EventLog constructor).
265
+ const statsFile = path.join(tempDir, 'logs', 'daily-stats.json');
266
+ fs.mkdirSync(path.dirname(statsFile), { recursive: true });
267
+ const legacyDailyStats = JSON.stringify({
268
+ [today]: {
269
+ date: today,
270
+ createdAt: new Date().toISOString(),
271
+ updatedAt: new Date().toISOString(),
272
+ tools: { total: 0, success: 0, failure: 0 },
273
+ pain: { signalsDetected: 0, avgScore: 0, maxScore: 0, signalsBySource: {} },
274
+ empathy: { totalEvents: 0, dedupedCount: 0, dedupeHitRate: 0, rolledBackScore: 0, rollbackCount: 0, bySeverity: { mild: 0, moderate: 0, severe: 0 }, scoreBySeverity: { mild: 0, moderate: 0, severe: 0 }, byDetectionMode: { structured: 0, legacy_tag: 0 }, byOrigin: { assistant_self_report: 0, user_manual: 0, system_infer: 0 }, confidenceDistribution: { high: 0, medium: 0, low: 0 }, dailyTrend: [] },
275
+ hooks: { total: 0, success: 0, failure: 0, byType: {} },
276
+ evolution: {
277
+ diagnosisTasksWritten: 0, heartbeatsInjected: 0,
278
+ diagnosticianReportsWritten: 1, // legacy success:true counted here
279
+ reportsMissingJson: 0, reportsIncompleteFields: 0,
280
+ principleCandidatesCreated: 0, rulesEnforced: 0,
281
+ nocturnalDreamerCompleted: 0, nocturnalArtifactPersisted: 0,
282
+ nocturnalCodeCandidateCreated: 0, rulehostEvaluated: 0,
283
+ rulehostBlocked: 0, rulehostRequireApproval: 0,
284
+ },
285
+ },
286
+ }, null, 2);
287
+ fs.writeFileSync(statsFile, legacyDailyStats, 'utf8');
288
+
289
+ // Create new EventLog instance so it loads the legacy stats via loadStats()
290
+ const reloaded = new EventLog(tempDir);
291
+ const stats = reloaded.getDailyStats(today);
292
+ expect(stats.evolution.diagnosticianReportsWritten).toBe(1);
293
+ });
294
+
295
+ it('should count incomplete_fields in both diagnosticianReportsWritten and reportsIncompleteFields', () => {
296
+ const today = new Date().toISOString().slice(0, 10);
297
+ eventLog.recordDiagnosticianReport({
298
+ taskId: 'task-incomplete',
299
+ reportPath: '/test/incomplete.json',
300
+ category: 'incomplete_fields',
301
+ });
302
+ eventLog.flush();
303
+
304
+ const stats = eventLog.getDailyStats(today);
305
+ expect(stats.evolution.diagnosticianReportsWritten).toBe(1);
306
+ expect(stats.evolution.reportsIncompleteFields).toBe(1);
307
+ // Other sub-counters should not be set
308
+ expect(stats.evolution.reportsMissingJson).toBe(0);
309
+ });
255
310
  });
256
311
  });
@@ -62,7 +62,25 @@ vi.mock('../../src/core/principle-tree-ledger.js', () => ({
62
62
  listImplementationsByLifecycleState: vi.fn(() => []),
63
63
  }));
64
64
 
65
+ // Shared mock instance — exposed as module-level so test assertions can reference it
66
+ const _mockEventLogInstance = {
67
+ recordGateBlock: vi.fn(),
68
+ recordPlanApproval: vi.fn(),
69
+ recordGateBypass: vi.fn(),
70
+ recordRuleEnforced: vi.fn(),
71
+ recordRuleHostRequireApproval: vi.fn(),
72
+ recordRuleHostEvaluated: vi.fn(),
73
+ recordPainSignal: vi.fn(),
74
+ };
75
+ vi.mock('../../src/core/event-log.js', () => ({
76
+ EventLogService: { get: vi.fn(() => _mockEventLogInstance) },
77
+ EventLog: {},
78
+ }));
79
+ // Export so test assertions can use vi.mocked() on the instance
80
+ export { _mockEventLogInstance };
81
+
65
82
  import { RuleHost } from '../../src/core/rule-host.js';
83
+ import { EventLogService } from '../../src/core/event-log.js';
66
84
  import * as sessionTrackerModule from '../../src/core/session-tracker.js';
67
85
  import * as evolutionEngineModule from '../../src/core/evolution-engine.js';
68
86
 
@@ -96,6 +114,8 @@ describe('Gate Rule Host Pipeline Integration', () => {
96
114
  recordGateBlock: vi.fn(),
97
115
  recordPlanApproval: vi.fn(),
98
116
  recordGateBypass: vi.fn(),
117
+ recordRuleEnforced: vi.fn(),
118
+ recordRuleHostRequireApproval: vi.fn(),
99
119
  };
100
120
 
101
121
  const mockTrajectory = {
@@ -310,14 +330,14 @@ describe('Gate Rule Host Pipeline Integration', () => {
310
330
 
311
331
  const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
312
332
 
313
- expect(result?.block).toBe(true);
314
- expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
315
- sessionId,
316
- expect.objectContaining({
317
- blockSource: 'rule-host',
318
- reason: expect.stringContaining('[Rule Host] Approval required'),
319
- })
333
+ expect(result?.block).toBeUndefined();
334
+ // requireApproval records events but does not block — the operation proceeds
335
+ // to the Progressive Trust Gate for further evaluation.
336
+ // recordRuleEnforced takes a single object arg (no sessionId).
337
+ expect(_mockEventLogInstance.recordRuleEnforced).toHaveBeenCalledWith(
338
+ expect.objectContaining({ enforcement: 'requireApproval' })
320
339
  );
340
+ expect(_mockEventLogInstance.recordGateBlock).not.toHaveBeenCalled();
321
341
  });
322
342
 
323
343
  // ═══════════════════════════════════════════════════════════════════════════