principles-disciple 1.60.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/diagnostician-task-store.ts +38 -1
- package/src/core/event-log.ts +87 -3
- package/src/core/workflow-funnel-loader.ts +170 -0
- package/src/hooks/gate.ts +43 -6
- package/src/service/evolution-worker.ts +99 -9
- package/src/service/nocturnal-service.ts +24 -3
- package/src/service/runtime-summary-service.ts +8 -0
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +16 -0
- package/src/types/event-types.ts +113 -3
- package/templates/langs/en/skills/pd-diagnostician/SKILL.md +98 -15
- 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
|
}
|
|
@@ -37,6 +37,8 @@ export interface DiagnosticianTask {
|
|
|
37
37
|
prompt: string;
|
|
38
38
|
createdAt: string;
|
|
39
39
|
status: 'pending' | 'completed';
|
|
40
|
+
/** Number of times task was retried due to marker exists but JSON report missing (#366) */
|
|
41
|
+
reportMissingRetries?: number;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface DiagnosticianTaskStore {
|
|
@@ -86,10 +88,12 @@ export async function addDiagnosticianTask(
|
|
|
86
88
|
|
|
87
89
|
|
|
88
90
|
const store = readTaskStoreSync(filePath);
|
|
91
|
+
const existing = store.tasks[taskId];
|
|
89
92
|
store.tasks[taskId] = {
|
|
90
93
|
prompt,
|
|
91
|
-
createdAt: new Date().toISOString(),
|
|
94
|
+
createdAt: existing?.createdAt ?? new Date().toISOString(),
|
|
92
95
|
status: 'pending',
|
|
96
|
+
reportMissingRetries: existing?.reportMissingRetries ?? 0,
|
|
93
97
|
};
|
|
94
98
|
atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
|
|
95
99
|
});
|
|
@@ -153,3 +157,36 @@ export function hasPendingDiagnosticianTasks(stateDir: string): boolean {
|
|
|
153
157
|
const store = readTaskStore(stateDir);
|
|
154
158
|
return Object.values(store.tasks).some(t => t.status === 'pending');
|
|
155
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Re-queue a diagnostician task with an incremented reportMissingRetries counter.
|
|
163
|
+
* Used when a task has a marker file but no JSON report — the worker re-injects
|
|
164
|
+
* the task for the LLM to retry (up to MAX_REPORT_MISSING_RETRIES times).
|
|
165
|
+
*
|
|
166
|
+
* Idempotent: if the task doesn't exist, does nothing.
|
|
167
|
+
*/
|
|
168
|
+
export async function requeueDiagnosticianTask(
|
|
169
|
+
stateDir: string,
|
|
170
|
+
taskId: string,
|
|
171
|
+
maxRetries = 3,
|
|
172
|
+
): Promise<{ requeued: boolean; maxRetriesReached: boolean }> {
|
|
173
|
+
const filePath = resolveTasksPath(stateDir);
|
|
174
|
+
return withLockAsync(filePath, async () => {
|
|
175
|
+
const store = readTaskStoreSync(filePath);
|
|
176
|
+
const existing = store.tasks[taskId];
|
|
177
|
+
if (!existing) {
|
|
178
|
+
return { requeued: false, maxRetriesReached: false };
|
|
179
|
+
}
|
|
180
|
+
const retries = (existing.reportMissingRetries ?? 0) + 1;
|
|
181
|
+
if (retries > maxRetries) {
|
|
182
|
+
return { requeued: false, maxRetriesReached: true };
|
|
183
|
+
}
|
|
184
|
+
store.tasks[taskId] = {
|
|
185
|
+
...existing,
|
|
186
|
+
status: 'pending',
|
|
187
|
+
reportMissingRetries: retries,
|
|
188
|
+
};
|
|
189
|
+
atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
|
|
190
|
+
return { requeued: true, maxRetriesReached: false };
|
|
191
|
+
});
|
|
192
|
+
}
|
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';
|
|
@@ -197,7 +205,14 @@ export class EventLog {
|
|
|
197
205
|
}
|
|
198
206
|
|
|
199
207
|
recordDiagnosticianReport(data: DiagnosticianReportEventData): void {
|
|
200
|
-
|
|
208
|
+
// Map three-state category to EventCategory
|
|
209
|
+
// Both missing_json and incomplete_fields map to 'failure' in EventCategory
|
|
210
|
+
const categoryMap: Record<DiagnosticianReportEventData['category'], EventCategory> = {
|
|
211
|
+
success: 'completed',
|
|
212
|
+
missing_json: 'failure',
|
|
213
|
+
incomplete_fields: 'failure',
|
|
214
|
+
};
|
|
215
|
+
this.record('diagnostician_report', categoryMap[data.category], undefined, data);
|
|
201
216
|
}
|
|
202
217
|
|
|
203
218
|
recordPrincipleCandidate(data: PrincipleCandidateEventData): void {
|
|
@@ -208,6 +223,32 @@ export class EventLog {
|
|
|
208
223
|
this.record('rule_enforced', 'matched', undefined, data);
|
|
209
224
|
}
|
|
210
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
|
+
|
|
211
252
|
private record(
|
|
212
253
|
type: EventType,
|
|
213
254
|
category: EventCategory,
|
|
@@ -356,14 +397,57 @@ export class EventLog {
|
|
|
356
397
|
} else if (entry.type === 'heartbeat_diagnosis') {
|
|
357
398
|
stats.evolution.heartbeatsInjected++;
|
|
358
399
|
} else if (entry.type === 'diagnostician_report') {
|
|
359
|
-
|
|
360
|
-
|
|
400
|
+
// Backward compat: handle old events with success:boolean and new events with category:string
|
|
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')) {
|
|
405
|
+
// New format: category is 'success' | 'missing_json' | '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') {
|
|
409
|
+
stats.evolution.diagnosticianReportsWritten++;
|
|
410
|
+
}
|
|
411
|
+
if (cat === 'missing_json') {
|
|
412
|
+
stats.evolution.reportsMissingJson++;
|
|
413
|
+
}
|
|
414
|
+
if (cat === 'incomplete_fields') {
|
|
415
|
+
stats.evolution.reportsIncompleteFields++;
|
|
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.
|
|
361
425
|
}
|
|
362
426
|
} else if (entry.type === 'principle_candidate') {
|
|
363
427
|
stats.evolution.principleCandidatesCreated++;
|
|
364
428
|
} else if (entry.type === 'rule_enforced') {
|
|
365
429
|
stats.evolution.rulesEnforced++;
|
|
366
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
|
+
}
|
|
367
451
|
}
|
|
368
452
|
|
|
369
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)}`);
|
|
@@ -12,7 +12,7 @@ import { SystemLogger } from '../core/system-logger.js';
|
|
|
12
12
|
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
13
13
|
import type { EventLog } from '../core/event-log.js';
|
|
14
14
|
import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
|
|
15
|
-
import { addDiagnosticianTask, completeDiagnosticianTask } from '../core/diagnostician-task-store.js';
|
|
15
|
+
import { addDiagnosticianTask, completeDiagnosticianTask, requeueDiagnosticianTask } from '../core/diagnostician-task-store.js';
|
|
16
16
|
import { getEvolutionLogger } from '../core/evolution-logger.js';
|
|
17
17
|
import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
|
|
18
18
|
import type { PrincipleEvaluability } from '../types/principle-tree-schema.js';
|
|
@@ -923,12 +923,59 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
923
923
|
|
|
924
924
|
let principlesGenerated = 0;
|
|
925
925
|
// C: Track report success for event recording
|
|
926
|
+
// FIX: Use reportParsed flag so reportSuccess=false when JSON is missing/garbled
|
|
926
927
|
let reportSuccess = false;
|
|
928
|
+
let reportParsed = false;
|
|
927
929
|
// Create principle from the diagnostician's JSON report.
|
|
928
930
|
const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
|
|
929
931
|
if (fs.existsSync(reportPath)) {
|
|
930
932
|
try {
|
|
931
|
-
const
|
|
933
|
+
const raw = fs.readFileSync(reportPath, 'utf8');
|
|
934
|
+
if (!raw || raw.trim().length === 0) {
|
|
935
|
+
throw new Error('Report file is empty');
|
|
936
|
+
}
|
|
937
|
+
const reportData = JSON.parse(raw);
|
|
938
|
+
if (!reportData) {
|
|
939
|
+
throw new Error('JSON parsed but content is null/undefined');
|
|
940
|
+
}
|
|
941
|
+
// Report is valid JSON — mark as parsed
|
|
942
|
+
reportParsed = true;
|
|
943
|
+
|
|
944
|
+
// FIX: Validate phase completeness before accepting the report
|
|
945
|
+
// A report missing critical phases is considered failed (not silently accepted).
|
|
946
|
+
// The diagnostician must produce all 4 diagnostic phases.
|
|
947
|
+
const phases = reportData?.phases || reportData?.diagnosis_report?.phases || {};
|
|
948
|
+
const requiredPhases = [
|
|
949
|
+
'evidence_gathering',
|
|
950
|
+
'causal_chain',
|
|
951
|
+
'root_cause_classification',
|
|
952
|
+
'principle_extraction',
|
|
953
|
+
];
|
|
954
|
+
const presentPhases = requiredPhases.filter(p =>
|
|
955
|
+
phases && Object.keys(phases).length > 0 && phases[p]
|
|
956
|
+
);
|
|
957
|
+
if (presentPhases.length < requiredPhases.length) {
|
|
958
|
+
const missing = requiredPhases.filter(p => !phases[p]);
|
|
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
|
+
}
|
|
970
|
+
// Treat as retryable failure: don't mark success, let retry logic kick in
|
|
971
|
+
reportParsed = false;
|
|
972
|
+
// Also delete the incomplete marker so next heartbeat re-runs the diagnostician
|
|
973
|
+
try { fs.unlinkSync(completeMarker); } catch { /* ignore if already gone */ }
|
|
974
|
+
task.status = 'pending';
|
|
975
|
+
task.resolution = undefined;
|
|
976
|
+
queueChanged = true;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
932
979
|
|
|
933
980
|
// ── Step 3: Noise Classification Filter ──
|
|
934
981
|
// Skip principle creation for low-value noise categories that don't represent
|
|
@@ -1048,15 +1095,52 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1048
1095
|
} catch (err) {
|
|
1049
1096
|
logger.warn(`[PD:EvolutionWorker] Failed to parse diagnostician report for task ${task.id}: ${String(err)}`);
|
|
1050
1097
|
}
|
|
1051
|
-
//
|
|
1052
|
-
reportSuccess
|
|
1098
|
+
// FIX: Only mark success if JSON was actually parsed and non-empty
|
|
1099
|
+
// If JSON was missing, garbled, or empty — reportSuccess stays false
|
|
1100
|
+
reportSuccess = reportParsed;
|
|
1053
1101
|
} else {
|
|
1054
|
-
|
|
1102
|
+
// ── #366: Marker exists but JSON report missing — retry logic ──
|
|
1103
|
+
// Do NOT mark completed yet. Re-inject the task for the next heartbeat cycle.
|
|
1104
|
+
// Read retry count from marker file content.
|
|
1105
|
+
const MAX_REPORT_MISSING_RETRIES = 3;
|
|
1106
|
+
let markerRetries = 0;
|
|
1107
|
+
try {
|
|
1108
|
+
const markerContent = fs.readFileSync(completeMarker, 'utf8');
|
|
1109
|
+
const match = markerContent.match(/report_missing_retries:(\d+)/);
|
|
1110
|
+
if (match) markerRetries = parseInt(match[1], 10);
|
|
1111
|
+
} catch { /* marker may not be readable, use 0 */ }
|
|
1112
|
+
|
|
1113
|
+
if (markerRetries < MAX_REPORT_MISSING_RETRIES) {
|
|
1114
|
+
// Re-inject: keep task in queue (don't mark completed), update marker with incremented count
|
|
1115
|
+
const newRetries = markerRetries + 1;
|
|
1116
|
+
if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id}: marker found but report missing — re-queuing (retry ${newRetries}/${MAX_REPORT_MISSING_RETRIES})`);
|
|
1117
|
+
// FIX: Update store's reportMissingRetries BEFORE deleting the marker.
|
|
1118
|
+
// This ensures the store's retry count is persisted even if the
|
|
1119
|
+
// diagnostician session crashes before re-adding the task.
|
|
1120
|
+
await requeueDiagnosticianTask(wctx.stateDir, task.id, MAX_REPORT_MISSING_RETRIES);
|
|
1121
|
+
// Also update the task in the main queue to keep it alive
|
|
1122
|
+
task.status = 'pending';
|
|
1123
|
+
task.resolution = undefined;
|
|
1124
|
+
queueChanged = true;
|
|
1125
|
+
// Delete the marker so the next heartbeat sees no marker
|
|
1126
|
+
// and re-processes the task as a fresh diagnostician run.
|
|
1127
|
+
try {
|
|
1128
|
+
fs.unlinkSync(completeMarker);
|
|
1129
|
+
} catch { /* ignore if already deleted */ }
|
|
1130
|
+
// Skip the completion/unlink block below — task is still pending
|
|
1131
|
+
continue;
|
|
1132
|
+
} else {
|
|
1133
|
+
// Max retries reached — accept that no report was produced
|
|
1134
|
+
if (logger) logger.warn(`[PD:EvolutionWorker] Task ${task.id}: max retries (${MAX_REPORT_MISSING_RETRIES}) reached — marking as failed_max_retries`);
|
|
1135
|
+
task.status = 'completed';
|
|
1136
|
+
task.completed_at = new Date().toISOString();
|
|
1137
|
+
task.resolution = 'failed_max_retries';
|
|
1138
|
+
}
|
|
1055
1139
|
}
|
|
1056
1140
|
|
|
1057
|
-
|
|
1058
|
-
task.
|
|
1059
|
-
|
|
1141
|
+
// Only reached if JSON existed or max retries reached:
|
|
1142
|
+
task.status = task.status || 'completed';
|
|
1143
|
+
task.completed_at = task.completed_at || new Date().toISOString();
|
|
1060
1144
|
if (!task.resolution) task.resolution = 'marker_detected';
|
|
1061
1145
|
try {
|
|
1062
1146
|
fs.unlinkSync(completeMarker);
|
|
@@ -1073,10 +1157,16 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
|
|
|
1073
1157
|
|
|
1074
1158
|
// C: Record diagnostician_report event for observability
|
|
1075
1159
|
if (eventLog) {
|
|
1160
|
+
// Map to three-state category:
|
|
1161
|
+
// - reportSuccess=true → 'success' (JSON exists, parsed, principle found)
|
|
1162
|
+
// - reportSuccess=false, reportParsed=true → 'incomplete_fields' (JSON existed but principle missing)
|
|
1163
|
+
// - reportSuccess=false, reportParsed=false → 'missing_json' (JSON never existed)
|
|
1164
|
+
const reportCategory: 'success' | 'missing_json' | 'incomplete_fields' =
|
|
1165
|
+
reportSuccess ? 'success' : reportParsed ? 'incomplete_fields' : 'missing_json';
|
|
1076
1166
|
eventLog.recordDiagnosticianReport({
|
|
1077
1167
|
taskId: task.id,
|
|
1078
1168
|
reportPath,
|
|
1079
|
-
|
|
1169
|
+
category: reportCategory,
|
|
1080
1170
|
});
|
|
1081
1171
|
}
|
|
1082
1172
|
|
|
@@ -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)}`);
|
|
@@ -69,6 +69,10 @@ export interface RuntimeSummary {
|
|
|
69
69
|
tasksWrittenToday: number;
|
|
70
70
|
/** Total diagnostician reports written (today from event log) */
|
|
71
71
|
reportsWrittenToday: number;
|
|
72
|
+
/** Total diagnostician reports that were missing JSON (category=missing_json) */
|
|
73
|
+
reportsMissingJsonToday: number;
|
|
74
|
+
/** Total diagnostician reports with incomplete fields (category=incomplete_fields) */
|
|
75
|
+
reportsIncompleteFieldsToday: number;
|
|
72
76
|
/** Total principle candidates created from heartbeat chain (today from event log) */
|
|
73
77
|
candidatesCreatedToday: number;
|
|
74
78
|
/** Heartbeats that injected diagnostician tasks (today from event log) */
|
|
@@ -194,6 +198,8 @@ export class RuntimeSummaryService {
|
|
|
194
198
|
evolution?: {
|
|
195
199
|
diagnosisTasksWritten?: number;
|
|
196
200
|
diagnosticianReportsWritten?: number;
|
|
201
|
+
reportsMissingJson?: number;
|
|
202
|
+
reportsIncompleteFields?: number;
|
|
197
203
|
principleCandidatesCreated?: number;
|
|
198
204
|
heartbeatsInjected?: number;
|
|
199
205
|
[key: string]: unknown;
|
|
@@ -265,6 +271,8 @@ export class RuntimeSummaryService {
|
|
|
265
271
|
pendingTasks: pendingDiagTasks.length,
|
|
266
272
|
tasksWrittenToday: diagDailyStats?.diagnosisTasksWritten ?? 0,
|
|
267
273
|
reportsWrittenToday: diagDailyStats?.diagnosticianReportsWritten ?? 0,
|
|
274
|
+
reportsMissingJsonToday: diagDailyStats?.reportsMissingJson ?? 0,
|
|
275
|
+
reportsIncompleteFieldsToday: diagDailyStats?.reportsIncompleteFields ?? 0,
|
|
268
276
|
candidatesCreatedToday: diagDailyStats?.principleCandidatesCreated ?? 0,
|
|
269
277
|
heartbeatsInjectedToday: diagDailyStats?.heartbeatsInjected ?? 0,
|
|
270
278
|
};
|
|
@@ -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.
|
|
@@ -209,7 +221,12 @@ export interface DiagnosisTaskEventData {
|
|
|
209
221
|
export interface DiagnosticianReportEventData {
|
|
210
222
|
taskId: string;
|
|
211
223
|
reportPath: string;
|
|
212
|
-
success
|
|
224
|
+
/** Three-state category replacing boolean success field.
|
|
225
|
+
* - 'success': JSON exists and has principle field
|
|
226
|
+
* - 'missing_json': marker exists but JSON does not (Issue #366, LLM output truncation)
|
|
227
|
+
* - 'incomplete_fields': JSON exists but missing principle field
|
|
228
|
+
*/
|
|
229
|
+
category: 'success' | 'missing_json' | 'incomplete_fields';
|
|
213
230
|
}
|
|
214
231
|
|
|
215
232
|
/**
|
|
@@ -232,6 +249,77 @@ export interface RuleEnforcedEventData {
|
|
|
232
249
|
filePath: string;
|
|
233
250
|
}
|
|
234
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
|
+
|
|
235
323
|
// ============== Daily Statistics ==============
|
|
236
324
|
|
|
237
325
|
export interface ToolCallStats {
|
|
@@ -326,8 +414,19 @@ export interface EvolutionStats {
|
|
|
326
414
|
diagnosisTasksWritten: number;
|
|
327
415
|
heartbeatsInjected: number;
|
|
328
416
|
diagnosticianReportsWritten: number;
|
|
417
|
+
reportsMissingJson: number;
|
|
418
|
+
reportsIncompleteFields: number;
|
|
329
419
|
principleCandidatesCreated: number;
|
|
330
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;
|
|
331
430
|
}
|
|
332
431
|
|
|
333
432
|
export interface HookStats {
|
|
@@ -490,8 +589,19 @@ export function createEmptyDailyStats(date: string): DailyStats {
|
|
|
490
589
|
diagnosisTasksWritten: 0,
|
|
491
590
|
heartbeatsInjected: 0,
|
|
492
591
|
diagnosticianReportsWritten: 0,
|
|
592
|
+
reportsMissingJson: 0,
|
|
593
|
+
reportsIncompleteFields: 0,
|
|
493
594
|
principleCandidatesCreated: 0,
|
|
494
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,
|
|
495
605
|
},
|
|
496
606
|
hooks: {
|
|
497
607
|
total: 0,
|
|
@@ -6,7 +6,7 @@ disable-model-invocation: true
|
|
|
6
6
|
|
|
7
7
|
# Diagnostician - Root Cause Analysis Agent
|
|
8
8
|
|
|
9
|
-
You are a professional root cause analysis expert. You MUST strictly follow the **
|
|
9
|
+
You are a professional root cause analysis expert. You MUST strictly follow the **six-phase protocol** (Phase 0 optional + Phase 1-5 mandatory) below to execute analysis and **immediately write results to the report file after each Phase completes**.
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -106,6 +106,20 @@ You are a professional root cause analysis expert. You MUST strictly follow the
|
|
|
106
106
|
}
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
+
**⚠️ Write Report File Immediately After Phase 1**:
|
|
110
|
+
Once Phase 1 is complete, **immediately** write the result to the report file (do NOT wait until the end):
|
|
111
|
+
```
|
|
112
|
+
write: .state/.diagnostician_report_<TASK_ID>.json
|
|
113
|
+
content: {
|
|
114
|
+
"taskId": "<TASK_ID>",
|
|
115
|
+
"completedAt": "<ISO timestamp>",
|
|
116
|
+
"phases": {
|
|
117
|
+
"evidence_gathering": { ...Phase 1 result... }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
If the file already exists (a previous Phase was already written), read the existing content, merge the new Phase result into it, then overwrite.
|
|
122
|
+
|
|
109
123
|
---
|
|
110
124
|
|
|
111
125
|
### Phase 2: Causal Chain Construction [Required]
|
|
@@ -145,6 +159,9 @@ You are a professional root cause analysis expert. You MUST strictly follow the
|
|
|
145
159
|
}
|
|
146
160
|
```
|
|
147
161
|
|
|
162
|
+
**⚠️ Write Report File Immediately After Phase 2**:
|
|
163
|
+
Once Phase 2 is complete, **immediately** merge the result into the report file (overwrite, do not lose Phase 1 content).
|
|
164
|
+
|
|
148
165
|
---
|
|
149
166
|
|
|
150
167
|
### Phase 3: Root Cause Classification [Required]
|
|
@@ -178,6 +195,9 @@ You are a professional root cause analysis expert. You MUST strictly follow the
|
|
|
178
195
|
}
|
|
179
196
|
```
|
|
180
197
|
|
|
198
|
+
**⚠️ Write Report File Immediately After Phase 3**:
|
|
199
|
+
Once Phase 3 is complete, **immediately** merge the result into the report file.
|
|
200
|
+
|
|
181
201
|
---
|
|
182
202
|
|
|
183
203
|
### Phase 4: Principle Extraction [Required]
|
|
@@ -264,9 +284,32 @@ You are a professional root cause analysis expert. You MUST strictly follow the
|
|
|
264
284
|
- "External dependency availability must be validated before invocation"
|
|
265
285
|
- "Code modifications must go through Issue process, ensuring traceability and rollback"
|
|
266
286
|
|
|
267
|
-
**
|
|
268
|
-
|
|
269
|
-
|
|
287
|
+
**Phase 4 Output Fields** (also write immediately after completing Phase 4 — merge with previous Phases):
|
|
288
|
+
```json
|
|
289
|
+
{
|
|
290
|
+
"taskId": "<TASK_ID>",
|
|
291
|
+
"completedAt": "<ISO timestamp>",
|
|
292
|
+
"phases": {
|
|
293
|
+
"context_extraction": { ... Phase 0 result ... },
|
|
294
|
+
"evidence_gathering": { ... Phase 1 result ... },
|
|
295
|
+
"causal_chain": { ... Phase 2 result ... },
|
|
296
|
+
"root_cause_classification": { ... Phase 3 result ... },
|
|
297
|
+
"principle_extraction": {
|
|
298
|
+
"phase": "principle_extraction",
|
|
299
|
+
"classification": {
|
|
300
|
+
"category": "development_transient|user_error|Design|Tooling|...",
|
|
301
|
+
"confidence": "high|medium|low",
|
|
302
|
+
"reproducible": true|false,
|
|
303
|
+
"severity": "high|medium|low"
|
|
304
|
+
},
|
|
305
|
+
"principle": { ... }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
**⚠️ Write Report File Immediately After Phase 4**:
|
|
312
|
+
Once Phase 4 is complete, **immediately** merge the result (with `classification` and `principle`) into the report file. This is the final write — all Phases must now be present.
|
|
270
313
|
|
|
271
314
|
---
|
|
272
315
|
|
|
@@ -285,20 +328,27 @@ Your diagnostic report will be **auto-parsed as JSON**. Any format errors will c
|
|
|
285
328
|
|
|
286
329
|
**Self-check method**: Before outputting, mentally verify: every `"` must have matching `"` after it, if content contains `"` it must be escaped as `\"`.
|
|
287
330
|
|
|
288
|
-
|
|
331
|
+
The final report (written incrementally by each Phase) should look like:
|
|
289
332
|
|
|
290
333
|
```json
|
|
291
334
|
{
|
|
292
|
-
"
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
"
|
|
296
|
-
"
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
"
|
|
301
|
-
"
|
|
335
|
+
"taskId": "<TASK_ID>",
|
|
336
|
+
"completedAt": "2026-03-24T10:30:00Z",
|
|
337
|
+
"phases": {
|
|
338
|
+
"context_extraction": { "phase": "context_extraction", "session_id": "...", "context_source": "...", "conversation_summary": "..." },
|
|
339
|
+
"evidence_gathering": { "phase": "evidence_gathering", "evidence": { ... } },
|
|
340
|
+
"causal_chain": { "phase": "causal_chain", "chain": [...], "terminated_at": 3, "termination_reason": "..." },
|
|
341
|
+
"root_cause_classification": { "phase": "root_cause_classification", "root_cause": "...", "category": "Design", "guardrail_analysis": { ... } },
|
|
342
|
+
"principle_extraction": {
|
|
343
|
+
"phase": "principle_extraction",
|
|
344
|
+
"classification": { "category": "Design", "confidence": "high", "reproducible": false, "severity": "low" },
|
|
345
|
+
"principle": {
|
|
346
|
+
"trigger_pattern": "...",
|
|
347
|
+
"action": "...",
|
|
348
|
+
"abstracted_principle": "...",
|
|
349
|
+
"duplicate": false,
|
|
350
|
+
"coreAxiomId": "T-02"
|
|
351
|
+
}
|
|
302
352
|
}
|
|
303
353
|
}
|
|
304
354
|
}
|
|
@@ -306,6 +356,38 @@ Merge outputs from all four phases into one JSON object:
|
|
|
306
356
|
|
|
307
357
|
---
|
|
308
358
|
|
|
359
|
+
## ✅ Completion Protocol
|
|
360
|
+
|
|
361
|
+
### ✅ Checklist (ALL must be satisfied before writing marker)
|
|
362
|
+
|
|
363
|
+
Before writing the marker file, you MUST confirm all of the following:
|
|
364
|
+
|
|
365
|
+
1. **Report file exists**: `.diagnostician_report_<TASK_ID>.json` has been written to disk
|
|
366
|
+
2. **All Phase fields present**:
|
|
367
|
+
- [ ] `phases.context_extraction` ✅
|
|
368
|
+
- [ ] `phases.evidence_gathering` ✅
|
|
369
|
+
- [ ] `phases.causal_chain` ✅
|
|
370
|
+
- [ ] `phases.root_cause_classification` ✅
|
|
371
|
+
- [ ] `phases.principle_extraction` ✅
|
|
372
|
+
3. **Report is valid JSON**: Use read tool to verify file content parses correctly
|
|
373
|
+
|
|
374
|
+
### ✅ Write Marker (Final Step)
|
|
375
|
+
|
|
376
|
+
**ONLY after confirming all conditions above are satisfied**, write the marker file:
|
|
377
|
+
```
|
|
378
|
+
write: .state/.evolution_complete_<TASK_ID>
|
|
379
|
+
content: diagnostic_completed: <ISO timestamp>
|
|
380
|
+
outcome: <one-sentence summary>
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### ❌ Forbidden
|
|
384
|
+
|
|
385
|
+
- **NEVER write marker before JSON** — marker means diagnosis is complete, JSON report must exist
|
|
386
|
+
- **NEVER skip any Phase** — even if a Phase seems inapplicable, write empty `{}`
|
|
387
|
+
- **NEVER use non-ASCII quotes in JSON** — must use `"`, not `"` `"`
|
|
388
|
+
|
|
389
|
+
---
|
|
390
|
+
|
|
309
391
|
## ⚠️ Execution Constraints
|
|
310
392
|
|
|
311
393
|
1. **NO skipping phases**: MUST attempt Phase 0 (context acquisition), then execute Phase 1 → 2 → 3 → 4 in order
|
|
@@ -313,6 +395,7 @@ Merge outputs from all four phases into one JSON object:
|
|
|
313
395
|
3. **NO vague conclusions**: Root cause must be specific and fixable
|
|
314
396
|
4. **NO skipping principle extraction**: Even for simple issues, extract principles
|
|
315
397
|
5. **NO skipping deduplication**: `duplicate` field MUST appear in principle_extraction output
|
|
398
|
+
6. **NO writing marker before all Phases complete**: Marker comes LAST, only after every Phase is written to JSON
|
|
316
399
|
|
|
317
400
|
---
|
|
318
401
|
|
|
@@ -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
|
// ═══════════════════════════════════════════════════════════════════════════
|