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.
@@ -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.60.0",
5
+ "version": "1.62.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -76,8 +76,8 @@
76
76
  }
77
77
  },
78
78
  "buildFingerprint": {
79
- "gitSha": "b300ef0761c5",
80
- "bundleMd5": "3c16a5198f3559b097d028b6e987cf5b",
81
- "builtAt": "2026-04-18T11:02:10.525Z"
79
+ "gitSha": "0d6db62f8866",
80
+ "bundleMd5": "9cf803e170837b148578f78ff3c77dbd",
81
+ "builtAt": "2026-04-18T23:04:51.214Z"
82
82
  }
83
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.60.0",
3
+ "version": "1.62.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -43,6 +43,7 @@
43
43
  "devDependencies": {
44
44
  "@testing-library/react": "^16.3.0",
45
45
  "@types/better-sqlite3": "^7.6.13",
46
+ "@types/js-yaml": "^4.0.9",
46
47
  "@types/micromatch": "^4.0.10",
47
48
  "@types/node": "^25.6.0",
48
49
  "@types/react": "^19.2.2",
@@ -70,6 +71,7 @@
70
71
  "@principles/core": "^0.1.0",
71
72
  "@sinclair/typebox": "^0.34.48",
72
73
  "better-sqlite3": "^12.9.0",
74
+ "js-yaml": "^4.1.1",
73
75
  "lucide-react": "^1.7.0",
74
76
  "micromatch": "^4.0.8",
75
77
  "react": "^19.2.0",
@@ -725,67 +725,59 @@ function restartGateway() {
725
725
  }
726
726
 
727
727
  /**
728
- * Restart Gateway on Windows using PowerShell.
728
+ * Restart Gateway on Windows via schtasks.
729
+ * Direct task trigger bypasses OpenClaw CLI's busy-port detection which can
730
+ * falsely report "still busy" due to TIME_WAIT connections on the port.
729
731
  */
730
732
  function restartGatewayWindows() {
731
733
  const logPath = join(getTempDir(), 'openclaw-auto-restart.log');
732
734
 
733
735
  try {
734
- // Step 1: Find and terminate existing gateway processes
735
- console.log(' Looking for existing gateway processes...');
736
+ // Kill existing gateway processes first (don't rely on schtasks stop)
737
+ console.log(' Stopping existing gateway processes...');
736
738
  try {
737
- // PowerShell command to find and kill openclaw gateway processes
738
- // Note: Use single quotes inside -like pattern for proper escaping
739
- const findCmd = "Get-Process -Name 'node' -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*openclaw*' } | Select-Object -ExpandProperty Id";
739
+ const findCmd = "Get-Process -Name 'node' -ErrorAction SilentlyContinue | Where-Object { \$_.CommandLine -like '*openclaw*' } | Select-Object -ExpandProperty Id";
740
740
  const pids = execSync(`powershell -NoProfile -Command "${findCmd}"`, { encoding: 'utf-8' }).trim();
741
-
742
741
  if (pids) {
743
- console.log(` Terminating existing gateway process(es): ${pids.replace(/\n/g, ', ')}...`);
744
- // Kill by PID
745
742
  const pidList = pids.split('\n').filter(p => p.trim());
746
743
  for (const pid of pidList) {
747
- try {
748
- execSync(`taskkill /PID ${pid.trim()} /F`, { stdio: 'pipe' });
749
- } catch { /* ignore if process already gone */ }
744
+ try { execSync(`taskkill /PID ${pid.trim()} /F`, { stdio: 'pipe' }); } catch { /* ignore */ }
750
745
  }
751
- // Wait a moment for process to terminate
752
- execSync('timeout /t 3 /nobreak > nul', { shell: true, stdio: 'ignore' });
746
+ // Wait for graceful shutdown
747
+ execSync('timeout /t 2 /nobreak > nul', { shell: true, stdio: 'ignore' });
753
748
  }
754
749
  } catch { /* no existing processes */ }
755
750
 
756
- // Step 2: Start new gateway process in background
757
- console.log(` Starting new gateway (logs: ${logPath})...`);
751
+ // Trigger via schtasks reliable, avoids CLI busy-port misdetection
752
+ console.log(' Starting gateway via scheduled task...');
753
+ execSync('schtasks /Run /TN "OpenClaw Gateway"', { stdio: 'inherit' });
758
754
 
759
- // Use openclaw CLI to start gateway (more reliable than direct node invocation)
760
- const gatewayCmd = join(getHomeDir(), '.openclaw', 'gateway.cmd');
761
- const startCmd = `Start-Process -FilePath 'cmd.exe' -ArgumentList '/c ${gatewayCmd}' -WindowStyle Hidden -RedirectStandardOutput '${logPath}' -RedirectStandardError '${join(getTempDir(), 'openclaw-auto-restart.err')}'`;
762
- execSync(`powershell -NoProfile -Command "${startCmd}"`, { stdio: 'inherit' });
763
- console.log('✅ Gateway restart triggered.');
755
+ // Wait for gateway to start and PD plugin to register
756
+ const deadline = Date.now() + 20000;
757
+ const pollInterval = 1000;
764
758
 
765
- // Step 3: Wait and verify
766
- setTimeout(() => {
759
+ const waitForRegistration = () => {
760
+ if (Date.now() > deadline) {
761
+ console.warn('⚠️ Gateway started but PD registration not confirmed after 20s.');
762
+ console.log(' Check logs at: ' + logPath);
763
+ return;
764
+ }
767
765
  try {
768
766
  if (existsSync(logPath)) {
769
767
  const logs = readFileSync(logPath, 'utf-8');
770
768
  if (logs.includes('Principles Disciple Plugin registered')) {
771
769
  console.log('✅ SUCCESS: Principles Disciple plugin registered successfully!');
772
- } else if (logs.includes('failed to load') || logs.includes('Error: Cannot find module')) {
773
- console.error('\n❌ CRITICAL: Gateway started but PD plugin FAILED to load!');
774
- console.error(' Check logs at: ' + logPath);
775
- process.exit(1);
776
- } else {
777
- console.warn('⚠️ Gateway started but PD registration not confirmed in recent logs.');
778
- console.log(' Check logs at: ' + logPath);
770
+ return;
779
771
  }
780
772
  }
781
- } catch (e) {
782
- console.warn(`⚠️ Post-restart verification skipped: ${e.message}`);
783
- }
784
- }, 8000);
773
+ } catch { /* ignore */ }
774
+ setTimeout(waitForRegistration, pollInterval);
775
+ };
776
+ waitForRegistration();
785
777
 
786
778
  } catch (error) {
787
- console.error(`\n❌ Failed to restart gateway: ${error.message}`);
788
- console.error(' You may need to manually restart OpenClaw Gateway.');
779
+ console.error(`\n❌ Gateway restart failed: ${error.message}`);
780
+ console.error(' You may need to manually restart: openclaw gateway start');
789
781
  process.exit(1);
790
782
  }
791
783
  }
@@ -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
+ }
@@ -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
- this.record('diagnostician_report', data.success ? 'completed' : 'failure', undefined, data);
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
- if (entry.category === 'completed') {
360
- stats.evolution.diagnosticianReportsWritten++;
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
- if (hostResult?.decision === 'block' || hostResult?.decision === 'requireApproval') {
208
+ // PD-FUNNEL-2.4: Always emit rulehost_evaluated on evaluate()
209
+ try {
210
+ const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
211
+ eventLog.recordRuleHostEvaluated({
212
+ toolName: event.toolName,
213
+ filePath: relPath,
214
+ matched: hostResult?.matched ?? false,
215
+ decision: hostResult?.decision ?? 'allow',
216
+ ruleId: hostResult?.ruleId,
217
+ });
218
+ } catch (evErr) {
219
+ logger?.warn?.(`[PD_GATE] Failed to record rulehost_evaluated: ${String(evErr)}`);
220
+ }
221
+ if (hostResult?.decision === 'block') {
209
222
  // C: Record rule_enforced event for matched rules
210
223
  try {
211
224
  const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
212
225
  eventLog.recordRuleEnforced({
213
226
  ruleId: hostResult.ruleId || 'unknown',
214
227
  principleId: hostResult.principleId || 'unknown',
215
- enforcement: hostResult.decision === 'requireApproval' ? 'requireApproval' : 'block',
228
+ enforcement: 'block',
216
229
  toolName: event.toolName,
217
230
  filePath: relPath,
218
231
  });
232
+ eventLog.recordRuleHostBlocked({
233
+ toolName: event.toolName,
234
+ filePath: relPath,
235
+ reason: hostResult.reason,
236
+ ruleId: hostResult.ruleId,
237
+ });
219
238
  } catch (evErr) {
220
- logger?.warn?.(`[PD_GATE] Failed to record rule_enforced event: ${String(evErr)}`);
239
+ logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_blocked event: ${String(evErr)}`);
221
240
  }
222
241
 
223
- const reason = hostResult.decision === 'requireApproval'
224
- ? `[Rule Host] Approval required: ${hostResult.reason}`
225
- : hostResult.reason;
242
+ const reason = hostResult.reason;
226
243
  return recordGateBlockAndReturn(wctx, {
227
244
  filePath: relPath,
228
245
  reason,
@@ -231,6 +248,26 @@ export function handleBeforeToolCall(
231
248
  blockSource: 'rule-host',
232
249
  }, logger);
233
250
  }
251
+ if (hostResult?.decision === 'requireApproval') {
252
+ try {
253
+ const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
254
+ eventLog.recordRuleEnforced({
255
+ ruleId: hostResult.ruleId || 'unknown',
256
+ principleId: hostResult.principleId || 'unknown',
257
+ enforcement: 'requireApproval',
258
+ toolName: event.toolName,
259
+ filePath: relPath,
260
+ });
261
+ eventLog.recordRuleHostRequireApproval({
262
+ toolName: event.toolName,
263
+ filePath: relPath,
264
+ reason: hostResult.reason,
265
+ ruleId: hostResult.ruleId,
266
+ });
267
+ } catch (evErr) {
268
+ logger?.warn?.(`[PD_GATE] Failed to record rule_enforced/rulehost_requireApproval event: ${String(evErr)}`);
269
+ }
270
+ }
234
271
  } catch (hostError: unknown) {
235
272
  // D-08: Conservative degradation — log and continue to Progressive Gate
236
273
  logger.warn?.(`[PD_GATE:RULE_HOST] Host evaluation failed, degrading conservatively: ${String(hostError)}`);
@@ -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 reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
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
- // C: Report was found and processed (try block succeeded or had non-fatal issues)
1052
- reportSuccess = true;
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
- logger.warn(`[PD:EvolutionWorker] No diagnostician report found for completed task ${task.id} (expected: .diagnostician_report_${task.id}.json)`);
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
- task.status = 'completed';
1058
- task.completed_at = new Date().toISOString();
1059
- // resolution already set by each branch (noise_classified | marker_detected)
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
- success: reportSuccess,
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
@@ -23,7 +23,15 @@ export type EventType =
23
23
  | 'heartbeat_diagnosis' // Heartbeat injected diagnostician tasks
24
24
  | 'diagnostician_report' // Diagnostician completed and wrote report
25
25
  | 'principle_candidate' // Principle candidate created from report
26
- | 'rule_enforced'; // Rule enforced (matched) during tool call
26
+ | 'rule_enforced' // Rule enforced (matched) during tool call
27
+ // C: Nocturnal funnel stage events (PD-FUNNEL-2.3)
28
+ | 'nocturnal_dreamer_completed'
29
+ | 'nocturnal_artifact_persisted'
30
+ | 'nocturnal_code_candidate_created'
31
+ // C: RuleHost funnel events (PD-FUNNEL-2.4)
32
+ | 'rulehost_evaluated'
33
+ | 'rulehost_blocked'
34
+ | 'rulehost_requireApproval';
27
35
 
28
36
  export type EventCategory =
29
37
  | 'success'
@@ -42,7 +50,11 @@ export type EventCategory =
42
50
  | 'written'
43
51
  | 'injected'
44
52
  | 'created'
45
- | 'matched';
53
+ | 'matched'
54
+ // C: New categories for RuleHost funnel (PD-FUNNEL-2.4) — completed/created already exist
55
+ | 'evaluated' // Used by: rulehost_evaluated
56
+ | 'blocked' // Used by: rulehost_blocked
57
+ | 'requireApproval'; // Used by: rulehost_requireApproval
46
58
 
47
59
  /**
48
60
  * Base event structure for JSONL logging.
@@ -209,7 +221,12 @@ export interface DiagnosisTaskEventData {
209
221
  export interface DiagnosticianReportEventData {
210
222
  taskId: string;
211
223
  reportPath: string;
212
- success: boolean;
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 **five-phase protocol** (Phase 0 optional + Phase 1-4 mandatory) below to execute analysis and output **JSON format** results.
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
- **Reference Existing Principle Styles** (you'll see existing principle entries in HEARTBEAT.md, keep consistent style):
268
- - P-10: Process as Authority — "When having technical capability to execute operations directly, must check if agreed-upon process exists"
269
- - P-11: Pre-write Validation — "Before writing to any high-risk path, first read to confirm file's current actual content"
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
- Merge outputs from all four phases into one JSON object:
331
+ The final report (written incrementally by each Phase) should look like:
289
332
 
290
333
  ```json
291
334
  {
292
- "diagnosis_report": {
293
- "task_id": "...",
294
- "timestamp": "2026-03-24T...",
295
- "summary": "One-sentence summary of root cause",
296
- "phases": {
297
- "context_extraction": { "session_id": "...", "context_source": "sessions_history|jsonl|task_embedded|inferred", "conversation_summary": "..." },
298
- "evidence_gathering": { ... },
299
- "causal_chain": { ... },
300
- "root_cause_classification": { ... },
301
- "principle_extraction": { ... }
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).toBe(true);
314
- expect(mockEventLog.recordGateBlock).toHaveBeenCalledWith(
315
- sessionId,
316
- expect.objectContaining({
317
- blockSource: 'rule-host',
318
- reason: expect.stringContaining('[Rule Host] Approval required'),
319
- })
333
+ expect(result?.block).toBeUndefined();
334
+ // requireApproval records events but does not block — the operation proceeds
335
+ // to the Progressive Trust Gate for further evaluation.
336
+ // recordRuleEnforced takes a single object arg (no sessionId).
337
+ expect(_mockEventLogInstance.recordRuleEnforced).toHaveBeenCalledWith(
338
+ expect.objectContaining({ enforcement: 'requireApproval' })
320
339
  );
340
+ expect(_mockEventLogInstance.recordGateBlock).not.toHaveBeenCalled();
321
341
  });
322
342
 
323
343
  // ═══════════════════════════════════════════════════════════════════════════