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.
- package/openclaw.plugin.json +4 -4
- package/package.json +3 -1
- package/scripts/sync-plugin.mjs +28 -36
- package/src/core/event-log.ts +71 -5
- package/src/core/workflow-funnel-loader.ts +170 -0
- package/src/hooks/gate.ts +43 -6
- package/src/service/evolution-worker.ts +10 -0
- package/src/service/nocturnal-service.ts +24 -3
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +16 -0
- package/src/types/event-types.ts +103 -2
- package/tests/core/event-log.test.ts +56 -1
- package/tests/hooks/gate-rule-host-pipeline.test.ts +27 -7
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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": "
|
|
80
|
-
"bundleMd5": "
|
|
81
|
-
"builtAt": "2026-04-
|
|
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.
|
|
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",
|
package/scripts/sync-plugin.mjs
CHANGED
|
@@ -725,67 +725,59 @@ function restartGateway() {
|
|
|
725
725
|
}
|
|
726
726
|
|
|
727
727
|
/**
|
|
728
|
-
* Restart Gateway on Windows
|
|
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
|
-
//
|
|
735
|
-
console.log('
|
|
736
|
+
// Kill existing gateway processes first (don't rely on schtasks stop)
|
|
737
|
+
console.log(' Stopping existing gateway processes...');
|
|
736
738
|
try {
|
|
737
|
-
|
|
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
|
|
752
|
-
execSync('timeout /t
|
|
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
|
-
//
|
|
757
|
-
console.log(
|
|
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
|
-
//
|
|
760
|
-
const
|
|
761
|
-
const
|
|
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
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
773
|
+
} catch { /* ignore */ }
|
|
774
|
+
setTimeout(waitForRegistration, pollInterval);
|
|
775
|
+
};
|
|
776
|
+
waitForRegistration();
|
|
785
777
|
|
|
786
778
|
} catch (error) {
|
|
787
|
-
console.error(`\n❌
|
|
788
|
-
console.error(' You may need to manually restart
|
|
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
|
}
|
package/src/core/event-log.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
411
|
+
if (cat === 'missing_json') {
|
|
374
412
|
stats.evolution.reportsMissingJson++;
|
|
375
413
|
}
|
|
376
|
-
if (
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
package/src/types/event-types.ts
CHANGED
|
@@ -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'
|
|
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).
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|