principles-disciple 1.16.0 → 1.18.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.
Files changed (132) hide show
  1. package/README.md +13 -5
  2. package/openclaw.plugin.json +4 -4
  3. package/package.json +1 -1
  4. package/src/commands/archive-impl.ts +3 -3
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +3 -3
  7. package/src/commands/disable-impl.ts +1 -1
  8. package/src/commands/evolution-status.ts +2 -2
  9. package/src/commands/focus.ts +2 -2
  10. package/src/commands/nocturnal-train.ts +6 -6
  11. package/src/commands/pain.ts +4 -4
  12. package/src/commands/pd-reflect.ts +87 -0
  13. package/src/commands/rollback-impl.ts +4 -4
  14. package/src/commands/rollback.ts +2 -2
  15. package/src/commands/samples.ts +2 -2
  16. package/src/commands/workflow-debug.ts +1 -1
  17. package/src/config/errors.ts +1 -1
  18. package/src/core/adaptive-thresholds.ts +1 -1
  19. package/src/core/code-implementation-storage.ts +2 -2
  20. package/src/core/config.ts +1 -1
  21. package/src/core/diagnostician-task-store.ts +2 -2
  22. package/src/core/empathy-keyword-matcher.ts +3 -3
  23. package/src/core/event-log.ts +5 -5
  24. package/src/core/evolution-engine.ts +4 -4
  25. package/src/core/evolution-logger.ts +1 -1
  26. package/src/core/evolution-reducer.ts +3 -3
  27. package/src/core/evolution-types.ts +5 -5
  28. package/src/core/external-training-contract.ts +1 -1
  29. package/src/core/focus-history.ts +14 -14
  30. package/src/core/hygiene/tracker.ts +1 -1
  31. package/src/core/init.ts +2 -2
  32. package/src/core/model-deployment-registry.ts +2 -2
  33. package/src/core/model-training-registry.ts +2 -2
  34. package/src/core/nocturnal-arbiter.ts +1 -1
  35. package/src/core/nocturnal-artificer.ts +2 -2
  36. package/src/core/nocturnal-candidate-scoring.ts +2 -2
  37. package/src/core/nocturnal-compliance.ts +4 -3
  38. package/src/core/nocturnal-dataset.ts +3 -3
  39. package/src/core/nocturnal-export.ts +4 -4
  40. package/src/core/nocturnal-rule-implementation-validator.ts +1 -1
  41. package/src/core/nocturnal-snapshot-contract.ts +112 -0
  42. package/src/core/nocturnal-trajectory-extractor.ts +7 -5
  43. package/src/core/nocturnal-trinity.ts +480 -158
  44. package/src/core/pain-context-extractor.ts +3 -3
  45. package/src/core/pain.ts +124 -11
  46. package/src/core/path-resolver.ts +4 -4
  47. package/src/core/pd-task-reconciler.ts +10 -10
  48. package/src/core/pd-task-service.ts +1 -1
  49. package/src/core/pd-task-store.ts +1 -1
  50. package/src/core/principle-internalization/deprecated-readiness.ts +1 -1
  51. package/src/core/principle-training-state.ts +2 -2
  52. package/src/core/principle-tree-ledger.ts +7 -7
  53. package/src/core/promotion-gate.ts +9 -9
  54. package/src/core/replay-engine.ts +12 -12
  55. package/src/core/risk-calculator.ts +1 -1
  56. package/src/core/rule-host-types.ts +2 -2
  57. package/src/core/rule-host.ts +5 -5
  58. package/src/core/schema/db-types.ts +1 -1
  59. package/src/core/schema/schema-definitions.ts +1 -1
  60. package/src/core/session-tracker.ts +96 -4
  61. package/src/core/shadow-observation-registry.ts +3 -3
  62. package/src/core/system-logger.ts +2 -2
  63. package/src/core/thinking-os-parser.ts +1 -1
  64. package/src/core/training-program.ts +2 -2
  65. package/src/core/trajectory.ts +8 -8
  66. package/src/core/workspace-context.ts +2 -2
  67. package/src/core/workspace-dir-service.ts +85 -0
  68. package/src/core/workspace-dir-validation.ts +30 -107
  69. package/src/hooks/bash-risk.ts +3 -3
  70. package/src/hooks/edit-verification.ts +4 -4
  71. package/src/hooks/gate-block-helper.ts +4 -4
  72. package/src/hooks/gate.ts +10 -10
  73. package/src/hooks/gfi-gate.ts +7 -7
  74. package/src/hooks/lifecycle.ts +2 -2
  75. package/src/hooks/llm.ts +1 -1
  76. package/src/hooks/pain.ts +25 -5
  77. package/src/hooks/progressive-trust-gate.ts +7 -7
  78. package/src/hooks/prompt.ts +24 -5
  79. package/src/hooks/subagent.ts +2 -2
  80. package/src/hooks/thinking-checkpoint.ts +2 -2
  81. package/src/hooks/trajectory-collector.ts +1 -1
  82. package/src/http/principles-console-route.ts +14 -6
  83. package/src/i18n/commands.ts +4 -0
  84. package/src/index.ts +181 -185
  85. package/src/service/central-health-service.ts +1 -1
  86. package/src/service/central-overview-service.ts +3 -3
  87. package/src/service/evolution-query-service.ts +1 -1
  88. package/src/service/evolution-worker.ts +221 -109
  89. package/src/service/health-query-service.ts +27 -17
  90. package/src/service/monitoring-query-service.ts +3 -3
  91. package/src/service/nocturnal-runtime.ts +4 -4
  92. package/src/service/nocturnal-service.ts +40 -23
  93. package/src/service/nocturnal-target-selector.ts +11 -4
  94. package/src/service/runtime-summary-service.ts +1 -1
  95. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +1 -1
  96. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +3 -3
  97. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +16 -13
  98. package/src/service/subagent-workflow/runtime-direct-driver.ts +10 -6
  99. package/src/service/subagent-workflow/types.ts +4 -4
  100. package/src/service/subagent-workflow/workflow-manager-base.ts +5 -5
  101. package/src/service/subagent-workflow/workflow-store.ts +2 -2
  102. package/src/tools/critique-prompt.ts +2 -3
  103. package/src/tools/deep-reflect.ts +17 -16
  104. package/src/tools/model-index.ts +1 -1
  105. package/src/utils/file-lock.ts +1 -1
  106. package/src/utils/io.ts +7 -2
  107. package/src/utils/nlp.ts +1 -1
  108. package/src/utils/plugin-logger.ts +2 -2
  109. package/src/utils/retry.ts +3 -2
  110. package/src/utils/subagent-probe.ts +20 -33
  111. package/templates/langs/en/skills/pd-pain-signal/SKILL.md +8 -7
  112. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/nocturnal-trinity-quality-enhancement.json +111 -0
  113. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/task-specs.mjs +1 -1
  114. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs +1 -1
  115. package/templates/langs/zh/skills/pd-pain-signal/SKILL.md +8 -7
  116. package/templates/pain_settings.json +1 -1
  117. package/tests/build-artifacts.test.ts +4 -58
  118. package/tests/commands/pd-reflect.test.ts +49 -0
  119. package/tests/core/nocturnal-snapshot-contract.test.ts +70 -0
  120. package/tests/core/pain-auto-repair.test.ts +96 -0
  121. package/tests/core/pain-integration.test.ts +483 -0
  122. package/tests/core/pain.test.ts +5 -4
  123. package/tests/core/workspace-dir-service.test.ts +68 -0
  124. package/tests/core/workspace-dir-validation.test.ts +56 -192
  125. package/tests/hooks/pain.test.ts +20 -0
  126. package/tests/http/principles-console-route.test.ts +42 -20
  127. package/tests/integration/empathy-workflow-integration.test.ts +1 -2
  128. package/tests/integration/tool-hooks-workspace-dir.e2e.test.ts +9 -17
  129. package/tests/service/empathy-observer-workflow-manager.test.ts +1 -2
  130. package/tests/service/evolution-worker.nocturnal.test.ts +118 -109
  131. package/tests/service/nocturnal-runtime-hardening.test.ts +33 -0
  132. package/tests/utils/subagent-probe.test.ts +32 -0
@@ -33,6 +33,7 @@ export interface SessionState {
33
33
  lastErrorSource?: string;
34
34
  lastErrorHash: string;
35
35
  consecutiveErrors: number;
36
+ lastGfiDecayAt?: number; // Timestamp of last GFI decay (for time-based decay)
36
37
 
37
38
  // Daily statistics (persisted)
38
39
  dailyToolCalls: number;
@@ -82,7 +83,7 @@ export function initPersistence(stateDir: string): void {
82
83
  }
83
84
 
84
85
  // Load all existing sessions
85
- // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Reason: loadAllSessions is defined later in this file, called here for organizational reasons
86
+
86
87
  loadAllSessions();
87
88
  }
88
89
 
@@ -138,6 +139,14 @@ function persistSession(state: SessionState): void {
138
139
 
139
140
  try {
140
141
  fs.writeFileSync(sessionPath, JSON.stringify(state, null, 2), 'utf-8');
142
+ // Log successful persistence with GFI snapshot for debugging
143
+ if (state.currentGfi > 0) {
144
+ SystemLogger.log(
145
+ state.workspaceDir,
146
+ 'GFI_PERSIST',
147
+ `Session ${state.sessionId.slice(0, 8)} persisted: GFI=${state.currentGfi.toFixed(1)}, sources=${JSON.stringify(state.gfiBySource)}`
148
+ );
149
+ }
141
150
  } catch (error) {
142
151
  logSessionTrackerWarning(`Failed to persist session ${state.sessionId}`, error);
143
152
  }
@@ -171,7 +180,7 @@ export function flushAllSessions(): void {
171
180
  }
172
181
  }
173
182
 
174
- // eslint-disable-next-line @typescript-eslint/max-params -- Reason: session creation requires all 4 params (sessionId, workspaceDir, sessionKey, trigger) - refactoring would break API
183
+
175
184
  function getOrCreateSession(sessionId: string, workspaceDir?: string, sessionKey?: string, trigger?: string): SessionState {
176
185
  let state = sessions.get(sessionId);
177
186
  if (!state) {
@@ -194,6 +203,7 @@ function getOrCreateSession(sessionId: string, workspaceDir?: string, sessionKey
194
203
  lastErrorSource: '',
195
204
  lastErrorHash: '',
196
205
  consecutiveErrors: 0,
206
+ lastGfiDecayAt: Date.now(),
197
207
  dailyToolCalls: 0,
198
208
  dailyToolFailures: 0,
199
209
  dailyPainSignals: 0,
@@ -232,7 +242,7 @@ export function trackToolRead(sessionId: string, filePath: string, workspaceDir?
232
242
  return state;
233
243
  }
234
244
 
235
- // eslint-disable-next-line @typescript-eslint/max-params -- Reason: LLM output tracking requires all 6 params - refactoring would break API
245
+
236
246
  export function trackLlmOutput(sessionId: string, usage: TokenUsage | undefined, config?: PainConfig, workspaceDir?: string, sessionKey?: string, trigger?: string): SessionState {
237
247
  const state = getOrCreateSession(sessionId, workspaceDir, sessionKey, trigger);
238
248
  state.llmTurns += 1;
@@ -271,7 +281,7 @@ export function trackLlmOutput(sessionId: string, usage: TokenUsage | undefined,
271
281
  /**
272
282
  * Tracks physical friction based on tool execution failures.
273
283
  */
274
- // eslint-disable-next-line @typescript-eslint/max-params -- Reason: friction tracking requires all 5 params - refactoring would break API
284
+
275
285
  export function trackFriction(
276
286
  sessionId: string,
277
287
  deltaF: number,
@@ -305,6 +315,8 @@ export function trackFriction(
305
315
  state.dailyGfiPeak = Math.max(state.dailyGfiPeak, state.currentGfi);
306
316
 
307
317
  // Schedule persistence
318
+ // Update decay anchor to prevent retroactive decay of the new friction
319
+ state.lastGfiDecayAt = Date.now();
308
320
  schedulePersistence(state);
309
321
 
310
322
  return state;
@@ -516,3 +528,83 @@ export function resetDailyStats(sessionId: string): void {
516
528
  schedulePersistence(state);
517
529
  }
518
530
  }
531
+
532
+ /**
533
+ * Apply time-based decay to GFI using segmented exponential decay.
534
+ *
535
+ * Decay rates:
536
+ * - GFI >= 70 (severe): 3%/min - fast recovery to avoid prolonged blocking
537
+ * - GFI 40-70 (moderate): 2%/min - medium decay
538
+ * - GFI < 40 (mild): 1%/min - slow decay to retain as warning
539
+ *
540
+ * Formula: GFI_new = GFI * (1 - λ)^elapsedMinutes
541
+ *
542
+ * @param sessionId - The session to decay
543
+ * @param elapsedMinutes - Minutes since last decay
544
+ * @returns Updated session state, or undefined if session not found or GFI is 0
545
+ */
546
+ export function decayGfi(sessionId: string, elapsedMinutes: number): SessionState | undefined {
547
+ const state = sessions.get(sessionId);
548
+ if (!state || state.currentGfi <= 0 || elapsedMinutes <= 0) return undefined;
549
+
550
+ // Determine decay rate based on current GFI level (segmented)
551
+ let decayRate: number;
552
+ if (state.currentGfi >= 70) {
553
+ decayRate = 0.03; // 3%/min for severe friction
554
+ } else if (state.currentGfi >= 40) {
555
+ decayRate = 0.02; // 2%/min for moderate friction
556
+ } else {
557
+ decayRate = 0.01; // 1%/min for mild friction
558
+ }
559
+
560
+ // Exponential decay: GFI_new = GFI * (1-λ)^Δt
561
+ const decayFactor = Math.pow(1 - decayRate, elapsedMinutes);
562
+ const previousGfi = state.currentGfi;
563
+ state.currentGfi = Math.max(0, state.currentGfi * decayFactor);
564
+
565
+ // Apply same decay factor to all sources
566
+ const ledger = ensureGfiLedger(state);
567
+ for (const source of Object.keys(ledger)) {
568
+ ledger[source] = Math.max(0, ledger[source] * decayFactor);
569
+ // Remove sources that have decayed below 0.1
570
+ if (ledger[source] < 0.1) {
571
+ delete ledger[source];
572
+ }
573
+ }
574
+
575
+ // Round to 1 decimal place
576
+ state.currentGfi = Math.round(state.currentGfi * 10) / 10;
577
+
578
+ // Update last decay timestamp
579
+ state.lastGfiDecayAt = Date.now();
580
+
581
+ // Log if significant decay
582
+ const decayedAmount = previousGfi - state.currentGfi;
583
+ if (decayedAmount >= 1) {
584
+ SystemLogger.log(
585
+ state.workspaceDir,
586
+ 'GFI_DECAY',
587
+ `GFI decayed by ${decayedAmount.toFixed(1)} (${elapsedMinutes}min at ${decayRate*100}%/min). ${previousGfi.toFixed(1)} → ${state.currentGfi.toFixed(1)}`
588
+ );
589
+ }
590
+
591
+ schedulePersistence(state);
592
+ return state;
593
+ }
594
+
595
+ /**
596
+ * Check if GFI decay should be applied and return elapsed minutes since last decay.
597
+ * @param sessionId - The session to check
598
+ * @returns Elapsed minutes since last decay, or 0 if no decay needed
599
+ */
600
+ export function getGfiDecayElapsed(sessionId: string): number {
601
+ const state = sessions.get(sessionId);
602
+ if (!state || state.currentGfi <= 0) return 0;
603
+
604
+ const now = Date.now();
605
+ const lastDecay = state.lastGfiDecayAt || state.lastControlActivityAt || state.lastActivityAt || now;
606
+ const elapsedMs = now - lastDecay;
607
+
608
+ // Return elapsed minutes (floor to whole minutes)
609
+ return Math.floor(elapsedMs / 60000);
610
+ }
@@ -222,12 +222,12 @@ function writeRegistry(stateDir: string, registry: ShadowRegistry): void {
222
222
  /**
223
223
  * Execute a read-modify-write under an exclusive file lock.
224
224
  */
225
- /* eslint-disable no-unused-vars -- Reason: registry param name in type signature intentionally unused - actual function uses different param name */
225
+
226
226
  function withShadowRegistryLock<T>(
227
227
  stateDir: string,
228
228
  fn: (_registry: ShadowRegistry) => T
229
229
  ): T {
230
- /* eslint-enable no-unused-vars */
230
+
231
231
  const registryPath = getRegistryPath(stateDir);
232
232
  return withLock(registryPath, () => {
233
233
  const registry = readRegistry(stateDir);
@@ -341,7 +341,7 @@ export function completeShadowObservation(
341
341
  * @param failureSignals - Runtime failure signals
342
342
  * @returns The updated ShadowObservation, or null if not found
343
343
  */
344
- // eslint-disable-next-line @typescript-eslint/max-params -- Reason: shadow observation completion requires all 4 params - refactoring would break API
344
+
345
345
  export function completeShadowObservationByTask(
346
346
  stateDir: string,
347
347
  taskFingerprint: string,
@@ -25,10 +25,10 @@ export const SystemLogger = {
25
25
  const logEntry = `[${timestamp}] [${eventType.padEnd(15)}] ${message}\n`;
26
26
 
27
27
  // Use fire-and-forget async append to prevent blocking
28
- fs.appendFile(logFile, logEntry, 'utf8', (_err) => { // eslint-disable-line @typescript-eslint/no-unused-vars, no-unused-vars -- Reason: fire-and-forget, errors silently dropped
28
+ fs.appendFile(logFile, logEntry, 'utf8', (_err) => {
29
29
  // Silently drop errors (e.g. disk full) to not crash the gateway
30
30
  });
31
- } catch (e) { // eslint-disable-line @typescript-eslint/no-unused-vars, no-unused-vars -- Reason: intentionally unused - silently fail if we can't setup the log
31
+ } catch (e) { // eslint-disable-line @typescript-eslint/no-unused-vars -- Reason: intentionally unused - silently fail if we can't setup the log
32
32
  // Silently fail if we can't setup the log
33
33
  }
34
34
  }
@@ -44,7 +44,7 @@ export function parseThinkingOsMd(content: string): ThinkingOsDirective[] {
44
44
 
45
45
  // Match all <directive ...> ... </directive> blocks
46
46
  const directiveRegex = /<directive\s+([^>]*)>([\s\S]*?)<\/directive>/gi;
47
- /* eslint-disable @typescript-eslint/init-declarations, @typescript-eslint/no-use-before-define, @typescript-eslint/prefer-destructuring, no-useless-assignment, @typescript-eslint/no-unused-vars */
47
+
48
48
  let match: RegExpExecArray | null = null;
49
49
 
50
50
  while ((match = directiveRegex.exec(content)) !== null) {
@@ -565,9 +565,9 @@ export function processTrainerResult(
565
565
  * ```
566
566
  */
567
567
  export class TrainingProgram {
568
- /* eslint-disable no-unused-vars -- Reason: stateDir is used via this.stateDir in createExperiment method */
568
+
569
569
  constructor(private readonly stateDir: string) {}
570
- /* eslint-enable no-unused-vars */
570
+
571
571
 
572
572
  /**
573
573
  * Create a new training experiment.
@@ -208,7 +208,7 @@ export class TrajectoryDatabase {
208
208
  const createdAt = input.createdAt ?? nowIso();
209
209
  // Extract filePath from paramsJson if provided and is an object with filePath
210
210
  const paramsObj = input.paramsJson as Record<string, unknown> | undefined;
211
- /* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars -- Reason: _filePath extracted for potential future use but currently unused */
211
+ /* eslint-disable @typescript-eslint/no-unused-vars -- Reason: _filePath extracted for potential future use but currently unused */
212
212
  const _filePath = paramsObj && typeof paramsObj.filePath === 'string' ? paramsObj.filePath : null;
213
213
  const rowId = this.withWrite(() => {
214
214
  const result = this.db.prepare(`
@@ -587,7 +587,7 @@ export class TrajectoryDatabase {
587
587
  const limit = filters.limit ?? 100;
588
588
  const offset = filters.offset ?? 0;
589
589
 
590
- // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in both if/else branches
590
+
591
591
  let rows: Record<string, unknown>[];
592
592
  if (traceId) {
593
593
  rows = this.db.prepare(`
@@ -783,7 +783,7 @@ export class TrajectoryDatabase {
783
783
  try {
784
784
  const params = JSON.parse(row.params_json);
785
785
  if (params && typeof params.filePath === 'string') {
786
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring -- Reason: filePath is a reassignable outer let variable - destructuring would lose the assignment semantics
786
+
787
787
  filePath = params.filePath;
788
788
  }
789
789
  } catch {
@@ -1324,7 +1324,7 @@ export class TrajectoryDatabase {
1324
1324
  this.importLegacyEvolution();
1325
1325
  }
1326
1326
 
1327
- /* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars -- Reason: _fromVersion reserved for future migration logic */
1327
+
1328
1328
  private migrateSchema(_fromVersion?: number): void {
1329
1329
  this.db.exec(`
1330
1330
  DROP VIEW IF EXISTS v_daily_metrics;
@@ -1545,7 +1545,7 @@ export class TrajectoryDatabase {
1545
1545
  });
1546
1546
  }
1547
1547
 
1548
- /* eslint-disable @typescript-eslint/max-params -- Reason: Audit record requires exportKind, mode, approvedOnly, filePath, and rowCount */
1548
+
1549
1549
  private recordExportAudit(
1550
1550
  exportKind: string,
1551
1551
  mode: CorrectionExportMode,
@@ -1610,7 +1610,7 @@ export class TrajectoryDatabase {
1610
1610
  for (const entry of fs.readdirSync(this.blobDir)) {
1611
1611
  if (referenced.has(entry)) continue;
1612
1612
  const fullPath = path.join(this.blobDir, entry);
1613
- // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in try, catch continues
1613
+
1614
1614
  let stat: fs.Stats;
1615
1615
  try {
1616
1616
  stat = fs.statSync(fullPath);
@@ -1660,9 +1660,9 @@ export class TrajectoryRegistry {
1660
1660
  this.instances.clear();
1661
1661
  }
1662
1662
 
1663
- /* eslint-disable no-unused-vars -- Reason: db parameter name in callback type signature */
1663
+
1664
1664
  static use<T>(workspaceDir: string, fn: (_db: TrajectoryDatabase) => T, opts: Omit<TrajectoryDatabaseOptions, 'workspaceDir'> = {}): T {
1665
- /* eslint-enable no-unused-vars */
1665
+
1666
1666
  const normalized = path.resolve(workspaceDir);
1667
1667
  const existing = this.instances.get(normalized);
1668
1668
  if (existing) {
@@ -21,13 +21,13 @@ import {
21
21
  import type { Principle, PrincipleValueMetrics } from '../types/principle-tree-schema.js';
22
22
  import type { Principle as ActivePrinciple } from './evolution-types.js';
23
23
 
24
- /* eslint-disable no-unused-vars -- Reason: interface method param names intentionally unused - implementations provide actual names */
24
+
25
25
  interface PrincipleTreeLedgerAccessor {
26
26
  getPrincipleSubtree(_principleId: string): PrincipleSubtree | undefined;
27
27
  updatePrinciple(_principleId: string, updates: Partial<Principle>): Principle;
28
28
  updatePrincipleValueMetrics(principleId: string, _metrics: PrincipleValueMetrics): PrincipleValueMetrics;
29
29
  }
30
- /* eslint-enable no-unused-vars */
30
+
31
31
 
32
32
  /**
33
33
  * WorkspaceContext - Centralized management of workspace-specific paths and services.
@@ -0,0 +1,85 @@
1
+ import type { OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
2
+ import { validateWorkspaceDir } from './workspace-dir-validation.js';
3
+
4
+ export interface WorkspaceResolutionContext {
5
+ workspaceDir?: string;
6
+ agentId?: string;
7
+ }
8
+
9
+ export interface WorkspaceResolutionOptions {
10
+ source?: string;
11
+ required?: boolean;
12
+ fallbackAgentId?: string;
13
+ logger?: PluginLogger;
14
+ }
15
+
16
+ function buildResolutionFailureMessage(source: string, attempts: string[]): string {
17
+ const suffix = attempts.length > 0 ? ` Attempts: ${attempts.join(' | ')}` : '';
18
+ return `[PD:WorkspaceDir] ${source}: unable to resolve a valid workspace directory.${suffix}`;
19
+ }
20
+
21
+ function tryResolveFromAgent(
22
+ api: OpenClawPluginApi,
23
+ agentId: string,
24
+ attempts: string[],
25
+ ): string | undefined {
26
+ try {
27
+ const resolved = api.runtime.agent.resolveAgentWorkspaceDir(api.config, agentId);
28
+ const issue = validateWorkspaceDir(resolved);
29
+ if (!issue) {
30
+ return resolved;
31
+ }
32
+ attempts.push(`agent:${agentId} invalid (${issue})`);
33
+ } catch (error) {
34
+ attempts.push(`agent:${agentId} threw (${String(error)})`);
35
+ }
36
+
37
+ return undefined;
38
+ }
39
+
40
+ export function resolveWorkspaceDir(
41
+ api: OpenClawPluginApi,
42
+ ctx: WorkspaceResolutionContext,
43
+ options: WorkspaceResolutionOptions = {},
44
+ ): string | undefined {
45
+ const source = options.source ?? 'unknown';
46
+ const logger = options.logger ?? api.logger;
47
+ const attempts: string[] = [];
48
+
49
+ if (ctx.workspaceDir) {
50
+ const issue = validateWorkspaceDir(ctx.workspaceDir);
51
+ if (!issue) {
52
+ return ctx.workspaceDir;
53
+ }
54
+ attempts.push(`ctx.workspaceDir invalid (${issue})`);
55
+ } else {
56
+ attempts.push('ctx.workspaceDir missing');
57
+ }
58
+
59
+ const agentCandidates = [ctx.agentId, options.fallbackAgentId]
60
+ .filter((value, index, all): value is string => !!value && all.indexOf(value) === index);
61
+
62
+ for (const agentId of agentCandidates) {
63
+ const resolved = tryResolveFromAgent(api, agentId, attempts);
64
+ if (resolved) {
65
+ return resolved;
66
+ }
67
+ }
68
+
69
+ const message = buildResolutionFailureMessage(source, attempts);
70
+ if (options.required) {
71
+ logger.error(message);
72
+ throw new Error(message);
73
+ }
74
+
75
+ logger.warn(message);
76
+ return undefined;
77
+ }
78
+
79
+ export function resolveRequiredWorkspaceDir(
80
+ api: OpenClawPluginApi,
81
+ ctx: WorkspaceResolutionContext,
82
+ options: Omit<WorkspaceResolutionOptions, 'required'> = {},
83
+ ): string {
84
+ return resolveWorkspaceDir(api, ctx, { ...options, required: true }) as string;
85
+ }
@@ -1,152 +1,75 @@
1
1
  /**
2
2
  * WorkspaceDir Validation Utilities
3
3
  *
4
- * Provides runtime validation of workspaceDir to catch OpenClaw context bugs early.
5
- * When a hook receives an invalid workspaceDir, we warn immediately rather than
6
- * silently writing to the wrong directory.
4
+ * This module only validates candidate workspace directories and delegates
5
+ * actual resolution policy to workspace-dir-service.ts.
7
6
  */
8
7
 
9
- /* eslint-disable no-unused-vars -- Reason: type definitions require param names that implementations may not use */
10
-
11
8
  import * as os from 'os';
12
9
  import type { PluginLogger } from '../openclaw-sdk.js';
10
+ import { resolveWorkspaceDir, type WorkspaceResolutionContext } from './workspace-dir-service.js';
13
11
 
14
- /**
15
- * Check if a path looks like a home directory (not a real workspace).
16
- * Returns the reason if suspicious, or null if it looks valid.
17
- */
18
12
  export function validateWorkspaceDir(dir: string | undefined): string | null {
19
13
  if (!dir) {
20
14
  return 'workspaceDir is undefined/null';
21
15
  }
22
-
16
+
23
17
  const homeDir = os.homedir();
24
-
25
- // Home directory itself is not a valid workspace
18
+
26
19
  if (dir === homeDir) {
27
20
  return `workspaceDir equals home directory (${homeDir}), likely missing context field`;
28
21
  }
29
-
30
- // Root directory is definitely not a workspace
22
+
31
23
  if (dir === '/' || dir === '') {
32
24
  return `workspaceDir is root or empty: "${dir}"`;
33
25
  }
34
-
35
- // Check if it looks like a resolved '.' that went wrong
36
- // Common bad patterns:
26
+
27
+ const escapedHome = homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
28
  const badPatterns = [
38
- // Directly under home without a workspace subdirectory
39
- { pattern: new RegExp(`^${homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), desc: 'is home directory itself' },
40
- { pattern: new RegExp(`^${homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/$`), desc: 'is home directory with trailing slash' },
29
+ { pattern: new RegExp(`^${escapedHome}$`), desc: 'is home directory itself' },
30
+ { pattern: new RegExp(`^${escapedHome}/$`), desc: 'is home directory with trailing slash' },
41
31
  ];
42
-
32
+
43
33
  for (const { pattern, desc } of badPatterns) {
44
34
  if (pattern.test(dir)) {
45
35
  return `workspaceDir ${desc}: "${dir}"`;
46
36
  }
47
37
  }
48
-
49
- return null; // Looks valid
38
+
39
+ return null;
50
40
  }
51
41
 
52
- /**
53
- * Try to resolve workspaceDir from agentId.
54
- * Returns the resolved path if successful and valid, or null.
55
- */
56
- function tryResolveFromAgentId(
57
- agentId: string,
42
+ export function resolveValidWorkspaceDir(
43
+ ctx: WorkspaceResolutionContext,
58
44
  api: {
59
45
  runtime: { agent: { resolveAgentWorkspaceDir: (config: unknown, agentId: string) => string } };
60
46
  config: unknown;
47
+ logger: PluginLogger;
61
48
  },
62
- onWarning: (msg: string) => void,
63
- ): string | null {
64
- try {
65
- const resolved = api.runtime.agent.resolveAgentWorkspaceDir(api.config, agentId);
66
- const issue = validateWorkspaceDir(resolved);
67
- if (issue) {
68
- onWarning(`agentId resolution returned invalid: "${resolved}" (${issue})`);
69
- return null;
70
- }
71
- return resolved;
72
- } catch (err) {
73
- onWarning(`failed to resolve from agentId: ${String(err)}`);
74
- return null;
75
- }
49
+ options?: { source?: string; fallbackAgentId?: string },
50
+ ): string | undefined {
51
+ return resolveWorkspaceDir(api as never, ctx, {
52
+ source: options?.source,
53
+ fallbackAgentId: options?.fallbackAgentId,
54
+ logger: api.logger,
55
+ });
76
56
  }
77
57
 
78
- /**
79
- * Validate a fallback workspaceDir and warn if invalid.
80
- * Returns the path regardless (it's the last resort).
81
- */
82
- function validateFallback(path: string, onWarning: (msg: string) => void): string {
83
- const issue = validateWorkspaceDir(path);
84
- if (issue) {
85
- onWarning(`FINAL FALLBACK "${path}" is also invalid: ${issue}. Events will be written to wrong location!`);
86
- }
87
- return path;
88
- }
89
-
90
- /**
91
- * Resolve workspaceDir with validation and warning.
92
- *
93
- * Usage:
94
- * const workspaceDir = resolveValidWorkspaceDir(ctx, api, { source: 'after_tool_call' });
95
- *
96
- * Fallback chain:
97
- * 1. ctx.workspaceDir (validated)
98
- * 2. api.runtime.agent.resolveAgentWorkspaceDir(config, ctx.agentId)
99
- * 3. api.resolvePath('.') (last resort, warns loudly)
100
- */
101
- export function resolveValidWorkspaceDir(
102
- ctx: { workspaceDir?: string; agentId?: string },
58
+ export function logWorkspaceDirHealth(
59
+ ctx: WorkspaceResolutionContext,
60
+ source: string,
103
61
  api: {
104
62
  runtime: { agent: { resolveAgentWorkspaceDir: (config: unknown, agentId: string) => string } };
105
63
  config: unknown;
106
- resolvePath: (input: string) => string;
107
64
  logger: PluginLogger;
108
65
  },
109
- options?: { source?: string; onWarning?: (msg: string) => void },
110
- ): string {
111
- const source = options?.source || 'unknown';
112
- const onWarning = options?.onWarning || ((msg: string) => api.logger.warn(`[PD:workspaceDir] ${msg}`));
113
-
114
- // 1. Try ctx.workspaceDir
115
- if (ctx.workspaceDir) {
116
- const issue = validateWorkspaceDir(ctx.workspaceDir);
117
- if (issue) {
118
- onWarning(`${source}: ctx.workspaceDir="${ctx.workspaceDir}" is invalid: ${issue}`);
119
- } else {
120
- return ctx.workspaceDir;
121
- }
122
- }
123
-
124
- // 2. Try agentId resolution
125
- if (ctx.agentId) {
126
- const fromAgent = tryResolveFromAgentId(ctx.agentId, api, onWarning);
127
- if (fromAgent) return fromAgent;
128
- }
129
-
130
- // 3. Final fallback
131
- return validateFallback(api.resolvePath('.'), onWarning);
132
- }
133
-
134
- /**
135
- * Log workspaceDir resolution for debugging.
136
- * Call this once during plugin startup to verify hook contexts.
137
- */
138
- export function logWorkspaceDirHealth(ctx: { workspaceDir?: string; agentId?: string }, source: string, api: {
139
- runtime: { agent: { resolveAgentWorkspaceDir: (config: unknown, agentId: string) => string } };
140
- config: unknown;
141
- resolvePath: (input: string) => string;
142
- logger: PluginLogger;
143
- }): void {
144
- const resolved = resolveValidWorkspaceDir(ctx, api, { source });
66
+ ): void {
67
+ const resolved = resolveValidWorkspaceDir(ctx, api, { source, fallbackAgentId: 'main' });
145
68
  const issue = validateWorkspaceDir(resolved);
146
-
69
+
147
70
  if (issue) {
148
71
  api.logger.error(`[PD:health] ${source}: workspaceDir="${resolved}" - ${issue}`);
149
72
  } else {
150
- api.logger.info(`[PD:health] ${source}: workspaceDir="${resolved}" ✓`);
73
+ api.logger.info(`[PD:health] ${source}: workspaceDir="${resolved}" OK`);
151
74
  }
152
75
  }
@@ -38,12 +38,12 @@ export type BashRiskLevel = 'safe' | 'dangerous' | 'normal';
38
38
  * @param logger - Optional logger for warnings about invalid patterns
39
39
  * @returns The risk level: 'safe', 'dangerous', or 'normal'
40
40
  */
41
- /* eslint-disable @typescript-eslint/max-params -- Reason: Bash risk analysis requires command + pattern lists - refactoring to options object would be breaking API change */
41
+
42
42
  export function analyzeBashCommand(
43
43
  command: string,
44
44
  safePatterns: string[],
45
45
  dangerousPatterns: string[],
46
- logger?: { warn?: (/* eslint-disable-line no-unused-vars -- Reason: callback parameter, unused by design */_message: string) => void }
46
+ logger?: { warn?: ( _message: string) => void }
47
47
  ): BashRiskLevel {
48
48
  let normalizedCmd = command.trim().toLowerCase();
49
49
 
@@ -65,7 +65,7 @@ export function analyzeBashCommand(
65
65
  // - Zero-width joiner (U+200D)
66
66
  // - Word joiner (U+2060)
67
67
  // - Zero-width invisible separator (U+FEFF)
68
- // eslint-disable-next-line no-misleading-character-class -- Reason: zero-width character class ranges are intentional - documented in comment above
68
+
69
69
  const ZERO_WIDTH_CHARS = /[\u200B\u200C\u200D\u2060\uFEFF]/g;
70
70
  if (ZERO_WIDTH_CHARS.test(command)) {
71
71
  logger?.warn?.(`[PD_GATE] Bash command contains zero-width characters — blocking as dangerous`);
@@ -126,7 +126,7 @@ This is enforced by P-03 (精确匹配前验证原则).`;
126
126
  * Handle edit tool verification before allowing operation
127
127
  * This enforces P-03 at the tool layer
128
128
  */
129
- // eslint-disable-next-line @typescript-eslint/max-params -- Reason: Hook handler signature requires event + context + config - refactoring would break plugin interface
129
+
130
130
  export function handleEditVerification(
131
131
  event: PluginHookBeforeToolCallEvent,
132
132
  wctx: WorkspaceContext,
@@ -155,11 +155,11 @@ export function handleEditVerification(
155
155
  }
156
156
 
157
157
  // 2. Resolve and read file
158
- // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in try, catch has early return
158
+
159
159
  let absolutePath: string;
160
160
  try {
161
161
  absolutePath = wctx.resolve(filePath);
162
- } catch (_error) { // eslint-disable-line @typescript-eslint/no-unused-vars, no-unused-vars -- Reason: intentionally unused - let it fail naturally on path resolution error
162
+ } catch (_error) { // eslint-disable-line @typescript-eslint/no-unused-vars -- Reason: intentionally unused - let it fail naturally on path resolution error
163
163
  // Path resolution error, let it fail naturally
164
164
  return;
165
165
  }
@@ -222,7 +222,7 @@ export function handleEditVerification(
222
222
  }
223
223
 
224
224
  // 3. Read current file content with improved error handling
225
- // eslint-disable-next-line @typescript-eslint/init-declarations -- assigned in try, all catch paths return early
225
+
226
226
  let currentContent: string;
227
227
  try {
228
228
  currentContent = fs.readFileSync(absolutePath, 'utf-8');
@@ -48,9 +48,9 @@ export interface BlockContext {
48
48
  export function recordGateBlockAndReturn(
49
49
  wctx: WorkspaceContext,
50
50
  blockCtx: BlockContext,
51
- /* eslint-disable no-unused-vars -- Reason: type-only callback parameters in logger type */
51
+
52
52
  logger: { warn?: (_message: string) => void; error?: (_message: string) => void; info?: (_message: string) => void }
53
- /* eslint-enable no-unused-vars */
53
+
54
54
  ): PluginHookBeforeToolCallResult {
55
55
  const { filePath, reason, toolName, sessionId, blockSource } = blockCtx;
56
56
 
@@ -93,7 +93,7 @@ export function recordGateBlockAndReturn(
93
93
  wctx.trajectory?.recordGateBlock?.(trajectoryPayload);
94
94
  } catch (error: unknown) {
95
95
  logWarn(`[PD_GATE] Failed to record trajectory gate block: ${String(error)}`);
96
- // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Reason: function is defined later but called in this helper for retry logic
96
+
97
97
  scheduleTrajectoryGateBlockRetry(wctx, trajectoryPayload, 1, logWarn, logError);
98
98
  }
99
99
 
@@ -133,7 +133,7 @@ This is a mandatory security gate. The operation was blocked because the modific
133
133
  * Uses exponential backoff with max retries.
134
134
  * Failures are logged but do not affect the runtime block decision.
135
135
  */
136
- /* eslint-disable @typescript-eslint/max-params, no-unused-vars -- Reason: Function requires all params for retry scheduling */
136
+
137
137
  function scheduleTrajectoryGateBlockRetry(
138
138
  wctx: WorkspaceContext,
139
139
  payload: {