principles-disciple 1.57.0 โ†’ 1.59.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.57.0",
5
+ "version": "1.59.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -76,8 +76,8 @@
76
76
  }
77
77
  },
78
78
  "buildFingerprint": {
79
- "gitSha": "70500e1475ef",
80
- "bundleMd5": "607cfbcb4534d2cfdc45cb0cd019cd0b",
81
- "builtAt": "2026-04-16T03:41:17.317Z"
79
+ "gitSha": "f37ee1dec538",
80
+ "bundleMd5": "f578c460a9849e9b6e6c573d13e48f12",
81
+ "builtAt": "2026-04-18T07:51:05.292Z"
82
82
  }
83
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.57.0",
3
+ "version": "1.59.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -644,11 +644,22 @@ function injectLocalWorkspacePackages() {
644
644
  console.log(' ๐Ÿ“ฆ Injecting local workspace packages (@principles/core)...');
645
645
  mkdirSync(dirname(targetModules), { recursive: true });
646
646
  // cpSync creates symlinks on Windows for symlinked dirs โ€” use cp -rL (dereference) via exec
647
+ let injected = false;
647
648
  try {
648
649
  execSync(`cp -rL "${monorepoModules}" "${targetModules}"`, { stdio: 'ignore' });
650
+ injected = true;
649
651
  } catch {
650
652
  // Fallback: manual copy via node (Windows-compatible)
651
- copyDir(monorepoModules, targetModules);
653
+ try {
654
+ copyDir(monorepoModules, targetModules);
655
+ injected = true;
656
+ } catch (copyErr) {
657
+ console.warn(' โš ๏ธ Failed to inject @principles/core from monorepo: ' + copyErr.message);
658
+ console.warn(' โš ๏ธ npm install --production may fail if @principles/core is not published');
659
+ }
660
+ }
661
+ if (injected && !existsSync(targetModules)) {
662
+ console.warn(' โš ๏ธ Injection reported success but target not found: ' + targetModules);
652
663
  }
653
664
  }
654
665
 
@@ -74,6 +74,14 @@ function buildEnglishOutput(
74
74
  `- Phase 3: ready ${summary.phase3.phase3ShadowEligible ? 'yes' : 'no'}, queueTruthReady ${summary.phase3.queueTruthReady ? 'yes' : 'no'}, eligible ${summary.phase3.evolutionEligible}, reference_only ${summary.phase3.evolutionReferenceOnly}, rejected ${summary.phase3.evolutionRejected}${summary.phase3.evolutionReferenceOnlyReasons.length > 0 ? ` (reference ${summary.phase3.evolutionReferenceOnlyReasons.slice(0, 2).join(', ')})` : ''}${summary.phase3.evolutionRejectedReasons.length > 0 ? ` (${summary.phase3.evolutionRejectedReasons.slice(0, 3).join(', ')})` : ''}`,
75
75
  `- Phase 3 Legacy Directive File: ${summary.phase3.directiveStatus} (${summary.phase3.directiveIgnoredReason})`,
76
76
  '',
77
+ // D: Heartbeat Diagnostician chain โ€” separated from evolution/nocturnal
78
+ 'Heartbeat Diagnostician (Pain โ†’ Principle)',
79
+ `- Pending tasks: ${summary.heartbeatDiagnosis.pendingTasks}`,
80
+ `- Tasks written today: ${summary.heartbeatDiagnosis.tasksWrittenToday}`,
81
+ `- Reports written today: ${summary.heartbeatDiagnosis.reportsWrittenToday}`,
82
+ `- Candidates created today: ${summary.heartbeatDiagnosis.candidatesCreatedToday}`,
83
+ `- Heartbeats injected today: ${summary.heartbeatDiagnosis.heartbeatsInjectedToday}`,
84
+ '',
77
85
  'Principles',
78
86
  `- candidate principles: ${stats.candidateCount}`,
79
87
  `- probation principles: ${stats.probationCount}`,
@@ -127,6 +135,14 @@ function buildChineseOutput(
127
135
  `- Phase 3: ready ${summary.phase3.phase3ShadowEligible ? 'yes' : 'no'}๏ผŒqueueTruthReady ${summary.phase3.queueTruthReady ? 'yes' : 'no'}๏ผŒeligible ${summary.phase3.evolutionEligible}๏ผŒreference_only ${summary.phase3.evolutionReferenceOnly}๏ผŒrejected ${summary.phase3.evolutionRejected}${summary.phase3.evolutionReferenceOnlyReasons.length > 0 ? ` (reference ${summary.phase3.evolutionReferenceOnlyReasons.slice(0, 2).join(', ')})` : ''}${summary.phase3.evolutionRejectedReasons.length > 0 ? ` (${summary.phase3.evolutionRejectedReasons.slice(0, 3).join(', ')})` : ''}`,
128
136
  `- Phase 3 Legacy Directive File: ${summary.phase3.directiveStatus} (${summary.phase3.directiveIgnoredReason})`,
129
137
  '',
138
+ // D: Heartbeat Diagnostician chain โ€” separated from evolution/nocturnal
139
+ 'ๅฟƒ่ทณ่ฏŠๆ–ญ้“พ่ทฏ๏ผˆPain โ†’ ๅŽŸๅˆ™๏ผ‰',
140
+ `- ็ญ‰ๅพ…ๅค„็†: ${summary.heartbeatDiagnosis.pendingTasks}`,
141
+ `- ไปŠๆ—ฅๅ†™ๅ…ฅไปปๅŠก: ${summary.heartbeatDiagnosis.tasksWrittenToday}`,
142
+ `- ไปŠๆ—ฅๅ†™ๅ…ฅๆŠฅๅ‘Š: ${summary.heartbeatDiagnosis.reportsWrittenToday}`,
143
+ `- ไปŠๆ—ฅๅˆ›ๅปบๅ€™้€‰: ${summary.heartbeatDiagnosis.candidatesCreatedToday}`,
144
+ `- ไปŠๆ—ฅๅฟƒ่ทณๆณจๅ…ฅ: ${summary.heartbeatDiagnosis.heartbeatsInjectedToday}`,
145
+ '',
130
146
  'ๅŽŸๅˆ™็ปŸ่ฎก',
131
147
  `- ๅ€™้€‰ๅŽŸๅˆ™: ${stats.candidateCount}`,
132
148
  `- ่ง‚ๅฏŸๆœŸๅŽŸๅˆ™: ${stats.probationCount}`,
@@ -18,6 +18,12 @@ import type {
18
18
  EvolutionTaskEventData,
19
19
  DeepReflectionEventData,
20
20
  EmpathyRollbackEventData,
21
+ // C: New event data types
22
+ DiagnosisTaskEventData,
23
+ HeartbeatDiagnosisEventData,
24
+ DiagnosticianReportEventData,
25
+ PrincipleCandidateEventData,
26
+ RuleEnforcedEventData,
21
27
  } from '../types/event-types.js';
22
28
  import { createEmptyDailyStats } from '../types/event-types.js';
23
29
  import { atomicWriteFileSync } from '../utils/io.js';
@@ -180,9 +186,28 @@ export class EventLog {
180
186
  recordWarn(sessionId: string | undefined, message: string, context?: Record<string, unknown>): void {
181
187
  this.record('warn', 'failure', sessionId, { message, ...context });
182
188
  }
183
-
184
-
185
-
189
+
190
+ // C: Diagnostician heartbeat chain event recorders
191
+ recordDiagnosisTask(data: DiagnosisTaskEventData): void {
192
+ this.record('diagnosis_task', 'written', undefined, data);
193
+ }
194
+
195
+ recordHeartbeatDiagnosis(data: HeartbeatDiagnosisEventData): void {
196
+ this.record('heartbeat_diagnosis', 'injected', undefined, data);
197
+ }
198
+
199
+ recordDiagnosticianReport(data: DiagnosticianReportEventData): void {
200
+ this.record('diagnostician_report', data.success ? 'completed' : 'failure', undefined, data);
201
+ }
202
+
203
+ recordPrincipleCandidate(data: PrincipleCandidateEventData): void {
204
+ this.record('principle_candidate', 'created', undefined, data);
205
+ }
206
+
207
+ recordRuleEnforced(data: RuleEnforcedEventData): void {
208
+ this.record('rule_enforced', 'matched', undefined, data);
209
+ }
210
+
186
211
  private record(
187
212
  type: EventType,
188
213
  category: EventCategory,
@@ -325,6 +350,20 @@ export class EventLog {
325
350
  stats.evolution.tasksEnqueued++;
326
351
  }
327
352
  }
353
+ // C: Diagnostician heartbeat chain event counters
354
+ else if (entry.type === 'diagnosis_task') {
355
+ stats.evolution.diagnosisTasksWritten++;
356
+ } else if (entry.type === 'heartbeat_diagnosis') {
357
+ stats.evolution.heartbeatsInjected++;
358
+ } else if (entry.type === 'diagnostician_report') {
359
+ if (entry.category === 'completed') {
360
+ stats.evolution.diagnosticianReportsWritten++;
361
+ }
362
+ } else if (entry.type === 'principle_candidate') {
363
+ stats.evolution.principleCandidatesCreated++;
364
+ } else if (entry.type === 'rule_enforced') {
365
+ stats.evolution.rulesEnforced++;
366
+ }
328
367
  }
329
368
 
330
369
  private startFlushTimer(): void {
package/src/core/init.ts CHANGED
@@ -192,7 +192,7 @@ export function ensureCorePrinciples(stateDir: string, logger: PluginLogger): bo
192
192
  for (const model of CORE_THINKING_MODELS) {
193
193
  const state: PrincipleTrainingState = {
194
194
  principleId: model.id,
195
- evaluability: 'manual_only',
195
+ evaluability: 'deterministic',
196
196
  applicableOpportunityCount: 0,
197
197
  observedViolationCount: 0,
198
198
  complianceRate: 0,
@@ -217,7 +217,7 @@ export function ensureCorePrinciples(stateDir: string, logger: PluginLogger): bo
217
217
  status: 'active',
218
218
  priority: 'P1',
219
219
  scope: 'general',
220
- evaluability: 'manual_only',
220
+ evaluability: 'deterministic',
221
221
  valueScore: 0,
222
222
  adherenceRate: 0,
223
223
  painPreventedCount: 0,
@@ -52,6 +52,11 @@ export function registerCompiledRule(stateDir: string, input: RegisterInput): Re
52
52
  const now = new Date().toISOString();
53
53
 
54
54
  // Step 1: Create the rule
55
+ // FIX: Auto-generated rules default to 'warn' enforcement (not 'block') until:
56
+ // - replay evaluation passes
57
+ // - coverage confirmation
58
+ // - human approval
59
+ // This prevents P_001-style false positives from blocking normal edits.
55
60
  const rule: LedgerRule = {
56
61
  id: ruleId,
57
62
  version: 1,
@@ -59,7 +64,7 @@ export function registerCompiledRule(stateDir: string, input: RegisterInput): Re
59
64
  description: `Automatically compiled gate rule generated from principle ${principleId}`,
60
65
  type: 'gate',
61
66
  triggerCondition: coversCondition,
62
- enforcement: 'block',
67
+ enforcement: 'warn',
63
68
  action: codeContent,
64
69
  principleId,
65
70
  status: 'proposed',
@@ -82,7 +87,11 @@ export function registerCompiledRule(stateDir: string, input: RegisterInput): Re
82
87
  version: '1',
83
88
  coversCondition,
84
89
  coveragePercentage: 100,
85
- lifecycleState: 'active' as const,
90
+ // FIX: Start as 'candidate' instead of 'active'.
91
+ // RuleHost only loads lifecycleState='active' implementations.
92
+ // This means auto-generated rules will NOT block until explicitly
93
+ // promoted to 'active' after replay evaluation + human approval.
94
+ lifecycleState: 'candidate' as const,
86
95
  createdAt: now,
87
96
  updatedAt: now,
88
97
  };
@@ -66,6 +66,10 @@ export interface RuleHostResult {
66
66
  matched: boolean;
67
67
  reason: string;
68
68
  diagnostics?: Record<string, unknown>;
69
+ /** C: Rule ID that produced this result (for observability events) */
70
+ ruleId?: string;
71
+ /** C: Principle ID that this rule implements (for observability events) */
72
+ principleId?: string;
69
73
  }
70
74
 
71
75
  // ---------------------------------------------------------------------------
@@ -234,7 +234,13 @@ export class RuleHost {
234
234
  meta,
235
235
  evaluate: (input: RuleHostInput): RuleHostResult => {
236
236
  const frozenHelpers = createRuleHostHelpers(input);
237
- return rawEvaluate(input, frozenHelpers);
237
+ const result = rawEvaluate(input, frozenHelpers);
238
+ // C: Enrich result with rule/principle IDs for observability
239
+ if (result.matched && (result.decision === 'block' || result.decision === 'requireApproval')) {
240
+ result.ruleId = impl.ruleId;
241
+ result.principleId = meta.ruleId ?? impl.ruleId;
242
+ }
243
+ return result;
238
244
  },
239
245
  };
240
246
  } catch (compileError: unknown) {
package/src/hooks/gate.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  } from '../constants/tools.js';
40
40
  import { getSession, hasRecentThinking } from '../core/session-tracker.js';
41
41
  import { getEvolutionEngine } from '../core/evolution-engine.js';
42
+ import { EventLogService } from '../core/event-log.js';
42
43
 
43
44
  export function handleBeforeToolCall(
44
45
  event: PluginHookBeforeToolCallEvent,
@@ -205,6 +206,20 @@ export function handleBeforeToolCall(
205
206
 
206
207
  const hostResult = ruleHost.evaluate(hostInput);
207
208
  if (hostResult?.decision === 'block' || hostResult?.decision === 'requireApproval') {
209
+ // C: Record rule_enforced event for matched rules
210
+ try {
211
+ const eventLog = EventLogService.get(wctx.stateDir, logger as PluginLogger | undefined);
212
+ eventLog.recordRuleEnforced({
213
+ ruleId: hostResult.ruleId || 'unknown',
214
+ principleId: hostResult.principleId || 'unknown',
215
+ enforcement: hostResult.decision === 'requireApproval' ? 'requireApproval' : 'block',
216
+ toolName: event.toolName,
217
+ filePath: relPath,
218
+ });
219
+ } catch (evErr) {
220
+ logger?.warn?.(`[PD_GATE] Failed to record rule_enforced event: ${String(evErr)}`);
221
+ }
222
+
208
223
  const reason = hostResult.decision === 'requireApproval'
209
224
  ? `[Rule Host] Approval required: ${hostResult.reason}`
210
225
  : hostResult.reason;
@@ -23,6 +23,7 @@ import {
23
23
  } from '../core/empathy-keyword-matcher.js';
24
24
  import { severityToPenalty, DEFAULT_EMPATHY_KEYWORD_CONFIG } from '../core/empathy-types.js';
25
25
  import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
26
+ import { EventLogService } from '../core/event-log.js';
26
27
  import type { PluginRuntimeSubagent } from '../service/subagent-workflow/runtime-direct-driver.js';
27
28
 
28
29
  /**
@@ -755,6 +756,18 @@ ${taskBlocks}${processingNote}
755
756
  </diagnostician_tasks>\n`;
756
757
 
757
758
  logger?.info?.(`[PD:Prompt] Injected ${Math.min(pendingCount, 3)}/${pendingCount} pending diagnostician task(s) into heartbeat prompt`);
759
+
760
+ // C: Record heartbeat_diagnosis event for observability
761
+ try {
762
+ const eventLog = EventLogService.get(wctx.stateDir, logger);
763
+ eventLog.recordHeartbeatDiagnosis({
764
+ taskCount: pendingCount,
765
+ taskIds: pendingTasks.slice(0, 3).map(t => t.id),
766
+ trigger: 'heartbeat',
767
+ });
768
+ } catch (evErr) {
769
+ logger?.warn?.(`[PD:Prompt] Failed to record heartbeat_diagnosis event: ${String(evErr)}`);
770
+ }
758
771
  }
759
772
  } catch (e) {
760
773
  logger?.warn?.(`[PD:Prompt] Failed to read diagnostician tasks: ${String(e)}`);
package/src/index.ts CHANGED
@@ -262,8 +262,13 @@ const plugin = {
262
262
 
263
263
  (event: PluginHookSubagentSpawningEvent, _ctx: PluginHookSubagentContext): void | PluginHookSubagentSpawningResult => {
264
264
  try {
265
- // Resolve workspace via official API, falling back to PathResolver
266
- const workspaceDir = resolveWorkspaceDirFromApi(api, event.agentId) || '.';
265
+ // FIX (B): Never fall back to '.' โ€” fail-fast with ERROR log if workspaceDir cannot be resolved.
266
+ // For subagent hooks, we use event.agentId as the target agent for workspace resolution.
267
+ const workspaceDir = resolveWorkspaceDirFromApi(api, event.agentId);
268
+ if (!workspaceDir) {
269
+ api.logger.error(`[PD] subagent_spawning: cannot resolve workspaceDir for agent "${event.agentId}" โ€” skipping shadow routing`);
270
+ return { status: 'ok' };
271
+ }
267
272
  api.logger?.debug?.(`[PD] workspaceDir resolved for subagent_spawning: ${workspaceDir}`);
268
273
  const { agentId, childSessionKey } = event;
269
274
  // Only handle PD local worker profiles
@@ -301,8 +306,12 @@ const plugin = {
301
306
  'subagent_ended',
302
307
  (event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext): void => {
303
308
  try {
304
- // Resolve workspace via official API, falling back to PathResolver
305
- const workspaceDir = resolveWorkspaceDirFromApi(api, undefined) || '.';
309
+ // FIX (B): Never fall back to '.' โ€” fail-fast with ERROR log if workspaceDir cannot be resolved.
310
+ const workspaceDir = resolveWorkspaceDirFromApi(api, undefined);
311
+ if (!workspaceDir) {
312
+ api.logger.error(`[PD] subagent_ended: cannot resolve workspaceDir โ€” skipping shadow observation completion`);
313
+ return;
314
+ }
306
315
  api.logger?.debug?.(`[PD] workspaceDir resolved for subagent_ended: ${workspaceDir}`);
307
316
  // Complete any pending shadow observation for this subagent session
308
317
  const shadowObsId = pendingShadowObservations.get(event.targetSessionKey);
@@ -922,6 +922,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
922
922
  if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} completed - marker file detected`);
923
923
 
924
924
  let principlesGenerated = 0;
925
+ // C: Track report success for event recording
926
+ let reportSuccess = false;
925
927
  // Create principle from the diagnostician's JSON report.
926
928
  const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
927
929
  if (fs.existsSync(reportPath)) {
@@ -1023,6 +1025,14 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1023
1025
  if (principleId) {
1024
1026
  logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from marker fallback for task ${task.id}`);
1025
1027
  principlesGenerated = 1;
1028
+ // C: Record principle_candidate_created event for observability
1029
+ if (eventLog) {
1030
+ eventLog.recordPrincipleCandidate({
1031
+ principleId,
1032
+ taskId: task.id,
1033
+ source: 'diagnostician',
1034
+ });
1035
+ }
1026
1036
  } else {
1027
1037
  logger.warn(`[PD:EvolutionWorker] createPrincipleFromDiagnosis returned null for task ${task.id} (may be duplicate or blacklisted)`);
1028
1038
  }
@@ -1038,6 +1048,8 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1038
1048
  } catch (err) {
1039
1049
  logger.warn(`[PD:EvolutionWorker] Failed to parse diagnostician report for task ${task.id}: ${String(err)}`);
1040
1050
  }
1051
+ // C: Report was found and processed (try block succeeded or had non-fatal issues)
1052
+ reportSuccess = true;
1041
1053
  } else {
1042
1054
  logger.warn(`[PD:EvolutionWorker] No diagnostician report found for completed task ${task.id} (expected: .diagnostician_report_${task.id}.json)`);
1043
1055
  }
@@ -1059,6 +1071,15 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1059
1071
  // FIX (#187): Remove the task from the diagnostician task store
1060
1072
  await completeDiagnosticianTask(wctx.stateDir, task.id);
1061
1073
 
1074
+ // C: Record diagnostician_report event for observability
1075
+ if (eventLog) {
1076
+ eventLog.recordDiagnosticianReport({
1077
+ taskId: task.id,
1078
+ reportPath,
1079
+ success: reportSuccess,
1080
+ });
1081
+ }
1082
+
1062
1083
  // Log to EvolutionLogger
1063
1084
  const durationMs = task.started_at
1064
1085
  ? Date.now() - new Date(task.started_at).getTime()
@@ -1349,6 +1370,15 @@ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogge
1349
1370
  await addDiagnosticianTask(wctx.stateDir, highestScoreTask.id, heartbeatContent);
1350
1371
  if (logger) logger.info(`[PD:EvolutionWorker] Wrote diagnostician task to diagnostician_tasks.json for task ${highestScoreTask.id}`);
1351
1372
 
1373
+ // C: Record diagnosis_task_written event for observability
1374
+ if (eventLog) {
1375
+ eventLog.recordDiagnosisTask({
1376
+ taskId: highestScoreTask.id,
1377
+ painEventId: highestScoreTask.painEventId !== undefined ? String(highestScoreTask.painEventId) : undefined,
1378
+ sessionId: highestScoreTask.session_id,
1379
+ });
1380
+ }
1381
+
1352
1382
  // Task store write succeeded, now mark task as in_progress
1353
1383
  highestScoreTask.task = taskDescription;
1354
1384
  highestScoreTask.status = 'in_progress';
@@ -5,6 +5,7 @@ import { listSessions } from '../core/session-tracker.js';
5
5
  import { WorkspaceContext } from '../core/workspace-context.js';
6
6
  import { evaluatePhase3Inputs } from './phase3-input-filter.js';
7
7
  import { TrajectoryRegistry } from '../core/trajectory.js';
8
+ import { getPendingDiagnosticianTasks } from '../core/diagnostician-task-store.js';
8
9
  import type { RuntimeTruth, AnalyticsTruth } from '../types/runtime-summary.js';
9
10
 
10
11
  export type RuntimeDataQuality = 'authoritative' | 'partial';
@@ -60,6 +61,19 @@ export interface RuntimeSummary {
60
61
  };
61
62
  dataQuality: RuntimeDataQuality;
62
63
  };
64
+ // D: Heartbeat Diagnostician chain โ€” separate from evolution/nocturnal chain
65
+ heartbeatDiagnosis: {
66
+ /** Tasks pending in diagnostician_tasks.json (not yet processed by heartbeat) */
67
+ pendingTasks: number;
68
+ /** Total diagnosis tasks written by evolution worker (today from event log) */
69
+ tasksWrittenToday: number;
70
+ /** Total diagnostician reports written (today from event log) */
71
+ reportsWrittenToday: number;
72
+ /** Total principle candidates created from heartbeat chain (today from event log) */
73
+ candidatesCreatedToday: number;
74
+ /** Heartbeats that injected diagnostician tasks (today from event log) */
75
+ heartbeatsInjectedToday: number;
76
+ };
63
77
  phase3: {
64
78
  queueTruthReady: boolean;
65
79
  phase3ShadowEligible: boolean;
@@ -177,6 +191,14 @@ export class RuntimeSummaryService {
177
191
  toolCalls?: number;
178
192
  painSignals?: number;
179
193
  evolutionTasks?: number;
194
+ evolution?: {
195
+ diagnosisTasksWritten?: number;
196
+ diagnosticianReportsWritten?: number;
197
+ principleCandidatesCreated?: number;
198
+ heartbeatsInjected?: number;
199
+ [key: string]: unknown;
200
+ };
201
+ [key: string]: unknown;
180
202
  }>>(
181
203
  path.join(wctx.stateDir, 'logs', 'daily-stats.json'),
182
204
  warnings,
@@ -233,6 +255,20 @@ export class RuntimeSummaryService {
233
255
  const gfiSources = this.buildGfiSources(events, selectedSessionId);
234
256
  const gateStats = this.buildGateStats(events, selectedSessionId, warnings);
235
257
 
258
+ // D: Heartbeat Diagnostician chain โ€” separate from evolution/nocturnal chain
259
+ // Read pending tasks from the diagnostician task store
260
+ const pendingDiagTasks = getPendingDiagnosticianTasks(wctx.stateDir);
261
+ // Read heartbeat diagnosis stats from daily event log
262
+ const todayStr = generatedAt.slice(0, 10);
263
+ const diagDailyStats = dailyStats?.[todayStr]?.evolution;
264
+ const heartbeatDiagnosis = {
265
+ pendingTasks: pendingDiagTasks.length,
266
+ tasksWrittenToday: diagDailyStats?.diagnosisTasksWritten ?? 0,
267
+ reportsWrittenToday: diagDailyStats?.diagnosticianReportsWritten ?? 0,
268
+ candidatesCreatedToday: diagDailyStats?.principleCandidatesCreated ?? 0,
269
+ heartbeatsInjectedToday: diagDailyStats?.heartbeatsInjected ?? 0,
270
+ };
271
+
236
272
  // Read trajectory analytics data (historical data, NOT runtime truth)
237
273
  const trajectoryStats = this.readTrajectoryStats(workspaceDir, warnings);
238
274
 
@@ -310,6 +346,8 @@ export class RuntimeSummaryService {
310
346
  lastSignal: lastPainSignal,
311
347
  },
312
348
  gate: gateStats,
349
+ // D: Heartbeat Diagnostician chain โ€” separate from evolution/nocturnal
350
+ heartbeatDiagnosis,
313
351
  metadata: {
314
352
  generatedAt,
315
353
  workspaceDir,
@@ -29,11 +29,10 @@ export function buildCritiquePromptV2(
29
29
  ): string {
30
30
  const { context, depth = 2, workspaceDir, api } = params;
31
31
 
32
- // 1. ็กฎๅฎšๅทฅไฝœๅŒบ็›ฎๅฝ• (ไผ˜ๅ…ˆ็บง๏ผšๆ˜พๅผไผ ๅ…ฅ > api.config > official API)
33
- const effectiveWorkspaceDir = workspaceDir
34
- || (api?.config?.workspaceDir as string)
35
- || resolveWorkspaceDirFromApi(api);
36
-
32
+ // FIX (B): Priority: explicitly passed workspaceDir > official API resolution
33
+ // Do NOT chain through api.config?.workspaceDir which may be stale.
34
+ const effectiveWorkspaceDir = workspaceDir || resolveWorkspaceDirFromApi(api);
35
+
37
36
  if (!effectiveWorkspaceDir) {
38
37
  throw new Error('Workspace directory is required for deep reflection.');
39
38
  }
@@ -148,10 +148,11 @@ export function createDeepReflectTool(api: OpenClawPluginApi) {
148
148
  * Resolve workspace directory for deep reflection tool.
149
149
  */
150
150
  function resolveReflectionWorkspace(api: OpenClawPluginApi): string {
151
- const dir = (api.config?.workspaceDir as string)
152
- || resolveWorkspaceDirFromApi(api);
151
+ // FIX (B): Only use resolveWorkspaceDirFromApi โ€” do not chain through api.config?.workspaceDir
152
+ // which may be stale. Fail-fast if workspace cannot be resolved.
153
+ const dir = resolveWorkspaceDirFromApi(api);
153
154
  if (!dir) {
154
- throw new WorkspaceNotFoundError('deep-reflect: workspace directory could not be resolved via API or config');
155
+ throw new WorkspaceNotFoundError('deep-reflect: workspace directory could not be resolved via API');
155
156
  }
156
157
  return dir;
157
158
  }
@@ -15,10 +15,15 @@ export type EventType =
15
15
  | 'plan_approval'
16
16
  | 'evolution_task'
17
17
  | 'deep_reflection'
18
-
19
18
  | 'empathy_rollback'
20
19
  | 'error'
21
- | 'warn';
20
+ | 'warn'
21
+ // C: Diagnostician heartbeat chain events
22
+ | 'diagnosis_task' // Diagnostician task written to task store
23
+ | 'heartbeat_diagnosis' // Heartbeat injected diagnostician tasks
24
+ | 'diagnostician_report' // Diagnostician completed and wrote report
25
+ | 'principle_candidate' // Principle candidate created from report
26
+ | 'rule_enforced'; // Rule enforced (matched) during tool call
22
27
 
23
28
  export type EventCategory =
24
29
  | 'success'
@@ -32,7 +37,12 @@ export type EventCategory =
32
37
  | 'promoted'
33
38
  | 'passed'
34
39
  | 'changed'
35
- | 'rolled_back';
40
+ | 'rolled_back'
41
+ // C: New categories for diagnostician heartbeat chain
42
+ | 'written'
43
+ | 'injected'
44
+ | 'created'
45
+ | 'matched';
36
46
 
37
47
  /**
38
48
  * Base event structure for JSONL logging.
@@ -174,6 +184,54 @@ export interface EmpathyRollbackEventData {
174
184
  triggeredBy: 'user_command' | 'natural_language' | 'system';
175
185
  }
176
186
 
187
+ /**
188
+ * C: New event data types for diagnostician heartbeat chain observability.
189
+ * Maps heartbeat_injected -> when prompt.ts injects diagnostician tasks into heartbeat
190
+ */
191
+ export interface HeartbeatDiagnosisEventData {
192
+ taskCount: number;
193
+ taskIds: string[];
194
+ trigger: 'heartbeat' | 'immediate';
195
+ }
196
+
197
+ /**
198
+ * Maps diagnosis_task_written -> when evolution-worker writes to diagnostician_tasks.json
199
+ */
200
+ export interface DiagnosisTaskEventData {
201
+ taskId: string;
202
+ painEventId?: string;
203
+ sessionId?: string;
204
+ }
205
+
206
+ /**
207
+ * Maps diagnostician_report_written -> when diagnostician completes and writes report
208
+ */
209
+ export interface DiagnosticianReportEventData {
210
+ taskId: string;
211
+ reportPath: string;
212
+ success: boolean;
213
+ }
214
+
215
+ /**
216
+ * Maps principle_candidate_created -> when evolution-worker extracts principle from report
217
+ */
218
+ export interface PrincipleCandidateEventData {
219
+ principleId: string;
220
+ taskId: string;
221
+ source: 'diagnostician' | 'nocturnal' | 'manual';
222
+ }
223
+
224
+ /**
225
+ * Maps rule_enforced -> when RuleHost evaluate() returns matched during tool call
226
+ */
227
+ export interface RuleEnforcedEventData {
228
+ ruleId: string;
229
+ principleId: string;
230
+ enforcement: 'warn' | 'block' | 'requireApproval';
231
+ toolName: string;
232
+ filePath: string;
233
+ }
234
+
177
235
  // ============== Daily Statistics ==============
178
236
 
179
237
  export interface ToolCallStats {
@@ -264,6 +322,12 @@ export interface EvolutionStats {
264
322
  tasksEnqueued: number;
265
323
  tasksCompleted: number;
266
324
  rulesPromoted: number;
325
+ // C: Diagnostician heartbeat chain counters
326
+ diagnosisTasksWritten: number;
327
+ heartbeatsInjected: number;
328
+ diagnosticianReportsWritten: number;
329
+ principleCandidatesCreated: number;
330
+ rulesEnforced: number;
267
331
  }
268
332
 
269
333
  export interface HookStats {
@@ -422,6 +486,12 @@ export function createEmptyDailyStats(date: string): DailyStats {
422
486
  tasksEnqueued: 0,
423
487
  tasksCompleted: 0,
424
488
  rulesPromoted: 0,
489
+ // C: Diagnostician heartbeat chain counters
490
+ diagnosisTasksWritten: 0,
491
+ heartbeatsInjected: 0,
492
+ diagnosticianReportsWritten: 0,
493
+ principleCandidatesCreated: 0,
494
+ rulesEnforced: 0,
425
495
  },
426
496
  hooks: {
427
497
  total: 0,
@@ -235,6 +235,20 @@ describe('bootstrap-rules', () => {
235
235
  // Act & Assert: Should throw
236
236
  expect(() => selectPrinciplesForBootstrap(stateDir, 3)).toThrow('No deterministic principles');
237
237
  });
238
+
239
+ // Regression test for Issue #356
240
+ it('T-01..T-10 as deterministic โ€” no crash on fresh workspace', () => {
241
+ const trainingStates = [
242
+ { principleId: 'T-01', evaluability: 'deterministic', applicableOpportunityCount: 0, observedViolationCount: 0, complianceRate: 1, violationTrend: 0, generatedSampleCount: 0, approvedSampleCount: 0, includedTrainRunIds: [], deployedCheckpointIds: [], internalizationStatus: 'needs_training' },
243
+ { principleId: 'T-02', evaluability: 'deterministic', applicableOpportunityCount: 0, observedViolationCount: 0, complianceRate: 1, violationTrend: 0, generatedSampleCount: 0, approvedSampleCount: 0, includedTrainRunIds: [], deployedCheckpointIds: [], internalizationStatus: 'needs_training' },
244
+ ];
245
+ const principles = trainingStates.map((s) => createLedgerPrinciple(s.principleId, { evaluability: s.evaluability }));
246
+ setupLedger(trainingStates, principles);
247
+ const selected = selectPrinciplesForBootstrap(stateDir, 3);
248
+ expect(selected).toHaveLength(2);
249
+ expect(selected).toContain('T-01');
250
+ expect(selected).toContain('T-02');
251
+ });
238
252
  });
239
253
 
240
254
  describe('bootstrapRules', () => {
@@ -4,6 +4,7 @@ import * as path from 'path';
4
4
  import { afterEach, describe, expect, it } from 'vitest';
5
5
  import { EvolutionReducerImpl } from '../../src/core/evolution-reducer.js';
6
6
  import { loadLedger } from '../../src/core/principle-tree-ledger.js';
7
+ import { safeRmDir } from '../test-utils.js';
7
8
 
8
9
  const tempDirs: string[] = [];
9
10
 
@@ -29,7 +30,7 @@ function makeStateDir(workspace: string): string {
29
30
 
30
31
  afterEach(() => {
31
32
  for (const dir of tempDirs.splice(0)) {
32
- fs.rmSync(dir, { recursive: true, force: true });
33
+ safeRmDir(dir);
33
34
  }
34
35
  });
35
36
 
@@ -106,7 +106,10 @@ describe('ledger-registrar', () => {
106
106
  expect(rule).toBeDefined();
107
107
  expect(rule.id).toBe('R_P_001_auto');
108
108
  expect(rule.type).toBe('gate');
109
- expect(rule.enforcement).toBe('block');
109
+ // FIX: Auto-generated rules default to 'warn' enforcement (not 'block')
110
+ // to prevent false positives like P_001 mis-blocking normal edits.
111
+ // They also start as 'candidate' lifecycle until replay evaluation passes.
112
+ expect(rule.enforcement).toBe('warn');
110
113
  expect(rule.status).toBe('proposed');
111
114
  expect(rule.principleId).toBe('P_001');
112
115
  expect(rule.implementationIds).toContain('IMPL_P_001_auto');
@@ -118,7 +121,7 @@ describe('ledger-registrar', () => {
118
121
  expect(impl.ruleId).toBe('R_P_001_auto');
119
122
  expect(impl.type).toBe('code');
120
123
  expect(impl.coversCondition).toBe('file_write');
121
- expect(impl.lifecycleState).toBe('active');
124
+ expect(impl.lifecycleState).toBe('candidate');
122
125
 
123
126
  // Verify principle linked to rule
124
127
  const principle = ledger.tree.principles['P_001'];
@@ -127,12 +127,14 @@ describe('PrincipleCompiler', () => {
127
127
  const rule = ledger.tree.rules['R_P_066_auto'];
128
128
  expect(rule).toBeDefined();
129
129
  expect(rule.type).toBe('gate');
130
- expect(rule.enforcement).toBe('block');
130
+ // FIX: Auto-generated rules default to 'warn' enforcement
131
+ expect(rule.enforcement).toBe('warn');
131
132
  expect(rule.status).toBe('proposed');
132
133
 
133
134
  const impl = ledger.tree.implementations['IMPL_P_066_auto'];
134
135
  expect(impl).toBeDefined();
135
- expect(impl.lifecycleState).toBe('active');
136
+ // FIX: Auto-generated implementations start as 'candidate' (not 'active')
137
+ expect(impl.lifecycleState).toBe('candidate');
136
138
  });
137
139
 
138
140
  // -----------------------------------------------------------------------
@@ -21,6 +21,7 @@ import {
21
21
  } from '../../src/core/principle-training-state.js';
22
22
  import { isExpectedSubagentError } from '../../src/service/subagent-workflow/subagent-error-utils.js';
23
23
  import { WorkspaceContext } from '../../src/core/workspace-context.js';
24
+ import { safeRmDir } from '../test-utils.js';
24
25
 
25
26
  const tempDirs: string[] = [];
26
27
 
@@ -32,7 +33,7 @@ function makeTempDir(): string {
32
33
 
33
34
  afterEach(() => {
34
35
  for (const dir of tempDirs.splice(0)) {
35
- fs.rmSync(dir, { recursive: true, force: true });
36
+ safeRmDir(dir);
36
37
  }
37
38
  });
38
39
 
@@ -16,6 +16,7 @@ import * as os from 'os';
16
16
  import * as path from 'path';
17
17
  import { TrajectoryDatabase } from '../../src/core/trajectory.js';
18
18
  import { EventLog } from '../../src/core/event-log.js';
19
+ import { safeRmDir } from '../test-utils.js';
19
20
 
20
21
  // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
21
22
  // Helper functions
@@ -83,12 +84,8 @@ function createTestWorkspace(): TestWorkspace {
83
84
 
84
85
  function cleanupWorkspace(ws: TestWorkspace | null): void {
85
86
  if (!ws) return;
86
- try {
87
- ws.trajectory?.dispose();
88
- fs.rmSync(ws.workspaceDir, { recursive: true, force: true });
89
- } catch {
90
- // ignore
91
- }
87
+ ws.trajectory?.dispose();
88
+ safeRmDir(ws.workspaceDir);
92
89
  }
93
90
 
94
91
  // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -237,8 +234,8 @@ describe('Gate: Resilience', () => {
237
234
 
238
235
  describe('RESILIENCE: Missing state directory', () => {
239
236
  it('EventLog MUST handle missing logs directory', () => {
240
- // Remove state directory
241
- fs.rmSync(ws!.stateDir, { recursive: true, force: true });
237
+ // Remove state directory (safeRmDir handles Windows EPERM from held handles)
238
+ safeRmDir(ws!.stateDir);
242
239
 
243
240
  // Attempt to create event log
244
241
  // Should recreate the directory
@@ -5,8 +5,9 @@
5
5
  * 1. recordPainEvent() returns AUTOINCREMENT row ID as number
6
6
  * 2. createPrincipleFromDiagnosis(painId: String(painEventId))
7
7
  * 3. derivedFromPainIds stores the stringified numeric ID
8
- * 4. PrincipleCompiler.compileOne() succeeds (registers active implementation)
9
- * 5. RuleHost.evaluate(matching input) โ†’ block
8
+ * 4. PrincipleCompiler.compileOne() succeeds (registers candidate implementation)
9
+ * 5. Promote to active
10
+ * 6. RuleHost.evaluate(matching input) โ†’ block
10
11
  * 6. RuleHost.evaluate(non-matching input) โ†’ undefined (passthrough)
11
12
  *
12
13
  * Pain ID chain fixed in commits 4b0dce59 and 0146bbb7:
@@ -25,7 +26,9 @@ import { RuleHost } from '../../src/core/rule-host.js';
25
26
  import { EvolutionReducerImpl } from '../../src/core/evolution-reducer.js';
26
27
  import {
27
28
  loadLedger,
29
+ transitionImplementationState,
28
30
  } from '../../src/core/principle-tree-ledger.js';
31
+ import { safeRmDir } from '../test-utils.js';
29
32
  import type { RuleHostInput } from '../../src/core/rule-host-types.js';
30
33
 
31
34
  // ---------------------------------------------------------------------------
@@ -52,7 +55,7 @@ function createTestWorkspace(): TestWorkspace {
52
55
 
53
56
  function disposeTestWorkspace(ws: TestWorkspace): void {
54
57
  ws.trajectory.dispose();
55
- fs.rmSync(ws.workspaceDir, { recursive: true, force: true });
58
+ safeRmDir(ws.workspaceDir);
56
59
  }
57
60
 
58
61
  // ---------------------------------------------------------------------------
@@ -131,12 +134,15 @@ describe('Pain ID Chain E2E: pain event โ†’ principle โ†’ compile โ†’ RuleHost',
131
134
  expect(compileResult.ruleId).toBeDefined();
132
135
  expect(compileResult.implementationId).toBeDefined();
133
136
 
134
- // Verify implementation is active
137
+ // Verify implementation is candidate (not active โ€” must be promoted before enforcing)
135
138
  const updatedLedger = loadLedger(ws.stateDir);
136
139
  const impl = updatedLedger.tree.implementations[compileResult.implementationId!];
137
- expect(impl.lifecycleState).toBe('active');
140
+ expect(impl.lifecycleState).toBe('candidate');
138
141
 
139
- // โ”€โ”€ Step 5: RuleHost.evaluate(matching input) โ†’ block โ”€โ”€
142
+ // โ”€โ”€ Step 5: Promote to active so RuleHost will enforce โ”€โ”€
143
+ transitionImplementationState(ws.stateDir, compileResult.implementationId!, 'active');
144
+
145
+ // โ”€โ”€ Step 6: RuleHost.evaluate(matching input) โ†’ block โ”€โ”€
140
146
  const host = new RuleHost(ws.stateDir, { warn: () => {} });
141
147
 
142
148
  const matchingInput: RuleHostInput = {
@@ -4,9 +4,11 @@
4
4
  * Tests the full chain:
5
5
  * 1. Set up principle in ledger with derivedFromPainIds
6
6
  * 2. Record tool call (bash, failure) and pain event in trajectory DB
7
- * 3. Compile principle via PrincipleCompiler (registers as active + persists code)
8
- * 4. RuleHost.evaluate(matching input) โ†’ block
9
- * 5. RuleHost.evaluate(non-matching input) โ†’ undefined (passthrough)
7
+ * 3. Compile principle via PrincipleCompiler (registers as 'candidate' โ€” NOT 'active')
8
+ * 4. RuleHost.evaluate(matching input) โ†’ NO block yet (candidate not loaded)
9
+ * 5. Promote implementation to 'active'
10
+ * 6. RuleHost.evaluate(matching input) โ†’ block
11
+ * 7. RuleHost.evaluate(non-matching input) โ†’ undefined (passthrough)
10
12
  */
11
13
 
12
14
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -19,6 +21,7 @@ import { RuleHost } from '../../src/core/rule-host.js';
19
21
  import {
20
22
  loadLedger,
21
23
  saveLedger,
24
+ transitionImplementationState,
22
25
  } from '../../src/core/principle-tree-ledger.js';
23
26
  import type { RuleHostInput } from '../../src/core/rule-host-types.js';
24
27
 
@@ -132,15 +135,14 @@ describe('Principle Compiler E2E: compile โ†’ promote โ†’ RuleHost blocks', () =
132
135
 
133
136
  const implId = result.implementationId!;
134
137
 
135
- // Verify the implementation was registered as active (not candidate)
138
+ // Verify the implementation was registered as 'candidate' (not 'active')
139
+ // FIX: Auto-generated implementations start as 'candidate' until explicitly promoted
140
+ // after replay evaluation and human approval. This prevents false-positive blocks.
136
141
  const ledger = loadLedger(ws.stateDir);
137
142
  const impl = ledger.tree.implementations[implId];
138
- expect(impl.lifecycleState).toBe('active');
143
+ expect(impl.lifecycleState).toBe('candidate');
139
144
 
140
- // โ”€โ”€ Step 4: Create RuleHost and evaluate with matching input โ”€โ”€
141
- const host = new RuleHost(ws.stateDir, { warn: () => {} });
142
-
143
- // Matching input: bash tool with a heartbeat command
145
+ // Define matching input for RuleHost evaluation (used in both Step 4 and Step 6)
144
146
  const matchingInput: RuleHostInput = {
145
147
  action: {
146
148
  toolName: 'bash',
@@ -166,6 +168,23 @@ describe('Principle Compiler E2E: compile โ†’ promote โ†’ RuleHost blocks', () =
166
168
  },
167
169
  };
168
170
 
171
+ // โ”€โ”€ Step 4: RuleHost should NOT block yet (candidate not loaded) โ”€โ”€
172
+ const hostBeforePromote = new RuleHost(ws.stateDir, { warn: () => {} });
173
+ const noBlockResult = hostBeforePromote.evaluate(matchingInput);
174
+ expect(noBlockResult).toBeUndefined(); // candidate not loaded โ†’ no block
175
+
176
+ // โ”€โ”€ Step 5: Promote to 'active' so RuleHost will enforce โ”€โ”€
177
+ transitionImplementationState(ws.stateDir, implId, 'active');
178
+
179
+ // Verify promotion
180
+ const ledgerAfterPromote = loadLedger(ws.stateDir);
181
+ const implAfterPromote = ledgerAfterPromote.tree.implementations[implId];
182
+ expect(implAfterPromote.lifecycleState).toBe('active');
183
+
184
+ // โ”€โ”€ Step 6: Create RuleHost and evaluate with matching input โ”€โ”€
185
+ const host = new RuleHost(ws.stateDir, { warn: () => {} });
186
+
187
+ // Matching input: bash tool with a heartbeat command (defined in Step 4)
169
188
  const blockResult = host.evaluate(matchingInput);
170
189
 
171
190
  // Verify RuleHost blocks the matching input
@@ -19,6 +19,7 @@ import { EvolutionReducerImpl } from '../../src/core/evolution-reducer.js';
19
19
  import { listEvaluablePrinciples, loadStore } from '../../src/core/principle-training-state.js';
20
20
  import { updateTrainingStore } from '../../src/core/principle-tree-ledger.js';
21
21
  import { PathResolver } from '../../src/core/path-resolver.js';
22
+ import { safeRmDir } from '../test-utils.js';
22
23
 
23
24
  describe('Principle Lifecycle E2E', () => {
24
25
  let tempDir: string;
@@ -40,7 +41,7 @@ describe('Principle Lifecycle E2E', () => {
40
41
  });
41
42
 
42
43
  afterEach(() => {
43
- fs.rmSync(tempDir, { recursive: true, force: true });
44
+ safeRmDir(tempDir);
44
45
  });
45
46
 
46
47
  describe('Training Store Integration (#204)', () => {