principles-disciple 1.37.0 → 1.39.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 (63) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/capabilities.ts +2 -1
  4. package/src/commands/context.ts +5 -4
  5. package/src/commands/focus.ts +5 -4
  6. package/src/commands/nocturnal-train.ts +13 -12
  7. package/src/commands/pd-reflect.ts +4 -3
  8. package/src/commands/promote-impl.ts +2 -1
  9. package/src/commands/rollback-impl.ts +6 -5
  10. package/src/core/adaptive-thresholds.ts +3 -4
  11. package/src/core/code-implementation-storage.ts +6 -5
  12. package/src/core/config.ts +3 -2
  13. package/src/core/correction-cue-learner.ts +42 -14
  14. package/src/core/correction-types.ts +1 -1
  15. package/src/core/diagnostician-task-store.ts +5 -8
  16. package/src/core/dictionary.ts +3 -2
  17. package/src/core/empathy-keyword-matcher.ts +5 -4
  18. package/src/core/event-log.ts +2 -1
  19. package/src/core/evolution-engine.ts +3 -3
  20. package/src/core/evolution-reducer.ts +4 -3
  21. package/src/core/focus-history.ts +20 -17
  22. package/src/core/hygiene/tracker.ts +2 -1
  23. package/src/core/init.ts +2 -1
  24. package/src/core/model-deployment-registry.ts +3 -4
  25. package/src/core/model-training-registry.ts +3 -4
  26. package/src/core/nocturnal-artifact-lineage.ts +2 -3
  27. package/src/core/nocturnal-dataset.ts +4 -5
  28. package/src/core/nocturnal-export.ts +8 -7
  29. package/src/core/pain.ts +6 -6
  30. package/src/core/path-resolver.ts +2 -1
  31. package/src/core/pd-task-reconciler.ts +4 -5
  32. package/src/core/pd-task-store.ts +3 -4
  33. package/src/core/principle-tree-ledger.ts +6 -5
  34. package/src/core/promotion-gate.ts +8 -9
  35. package/src/core/replay-engine.ts +10 -10
  36. package/src/core/session-tracker.ts +2 -1
  37. package/src/core/shadow-observation-registry.ts +3 -4
  38. package/src/core/training-program.ts +2 -1
  39. package/src/core/trajectory.ts +8 -7
  40. package/src/hooks/gate-block-helper.ts +3 -3
  41. package/src/hooks/lifecycle.ts +3 -2
  42. package/src/hooks/llm.ts +3 -2
  43. package/src/hooks/prompt.ts +77 -18
  44. package/src/service/cooldown-strategy.ts +97 -0
  45. package/src/service/evolution-worker.ts +133 -272
  46. package/src/service/failure-classifier.ts +79 -0
  47. package/src/service/keyword-optimization-service.ts +22 -15
  48. package/src/service/nocturnal-config.ts +149 -7
  49. package/src/service/nocturnal-runtime.ts +24 -4
  50. package/src/service/nocturnal-service.ts +1 -8
  51. package/src/service/startup-reconciler.ts +112 -0
  52. package/src/service/subagent-workflow/correction-observer-types.ts +2 -2
  53. package/src/service/subagent-workflow/correction-observer-workflow-manager.ts +1 -1
  54. package/src/service/subagent-workflow/index.ts +0 -14
  55. package/src/service/subagent-workflow/workflow-manager-base.ts +5 -5
  56. package/src/tools/write-pain-flag.ts +2 -3
  57. package/src/utils/io.ts +43 -1
  58. package/tests/core/dictionary.test.ts +4 -1
  59. package/tests/service/cooldown-strategy.test.ts +163 -0
  60. package/tests/service/failure-classifier.test.ts +171 -0
  61. package/tests/service/startup-reconciler.test.ts +148 -0
  62. package/src/service/correction-observer-types.ts +0 -69
  63. package/src/service/correction-observer-workflow-manager.ts +0 -247
@@ -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.37.0",
5
+ "version": "1.39.0",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.37.0",
3
+ "version": "1.39.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -3,6 +3,7 @@ import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
5
5
  import { WorkspaceContext } from '../core/workspace-context.js';
6
+ import { atomicWriteFileSync } from '../utils/io.js';
6
7
 
7
8
  const TOOLS_TO_SCAN = [
8
9
  { name: 'rg', cmd: ['rg', '--version'] },
@@ -43,7 +44,7 @@ function scanEnvironment(wctx: WorkspaceContext): any {
43
44
  if (!fs.existsSync(capsDir)) {
44
45
  fs.mkdirSync(capsDir, { recursive: true });
45
46
  }
46
- fs.writeFileSync(capsPath, JSON.stringify(capabilities, null, 2), 'utf8');
47
+ atomicWriteFileSync(capsPath, JSON.stringify(capabilities, null, 2));
47
48
 
48
49
  return capabilities;
49
50
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
4
+ import { atomicWriteFileSync } from '../utils/io.js';
4
5
  import type { ContextInjectionConfig} from '../types.js';
5
6
  import { defaultContextConfig } from '../types.js';
6
7
  import { loadContextInjectionConfig } from '../hooks/prompt.js';
@@ -40,7 +41,7 @@ function saveConfig(workspaceDir: string, config: ContextInjectionConfig): boole
40
41
  profile.contextInjection = config;
41
42
 
42
43
  // Write back
43
- fs.writeFileSync(profilePath, JSON.stringify(profile, null, 2), 'utf-8');
44
+ atomicWriteFileSync(profilePath, JSON.stringify(profile, null, 2));
44
45
  return true;
45
46
  } catch (e) {
46
47
  console.error(`[PD:Context] Failed to save config: ${String(e)}`);
@@ -99,7 +100,7 @@ function showStatus(workspaceDir: string, isZh: boolean): string {
99
100
  * Toggle a boolean setting
100
101
  */
101
102
 
102
- // eslint-disable-next-line @typescript-eslint/max-params
103
+
103
104
  function toggleSetting(
104
105
  workspaceDir: string,
105
106
  key: 'thinkingOs' | 'reflectionLog',
@@ -215,7 +216,7 @@ function applyPreset(
215
216
  isZh: boolean
216
217
  ): string {
217
218
 
218
- // eslint-disable-next-line @typescript-eslint/init-declarations
219
+
219
220
  let config: ContextInjectionConfig;
220
221
 
221
222
  switch (preset) {
@@ -318,7 +319,7 @@ export function handleContextCommand(ctx: PluginCommandContext): PluginCommandRe
318
319
  const isZh = (ctx.config?.language as string) === 'zh';
319
320
 
320
321
 
321
- // eslint-disable-next-line @typescript-eslint/init-declarations
322
+
322
323
  let result: string;
323
324
 
324
325
  switch (subCommand) {
@@ -12,6 +12,7 @@ import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import type { PluginCommandContext, PluginCommandResult, OpenClawPluginApi } from '../openclaw-sdk.js';
14
14
  import { WorkspaceContext } from '../core/workspace-context.js';
15
+ import { atomicWriteFileSync } from '../utils/io.js';
15
16
  import {
16
17
  getHistoryDir,
17
18
  backupToHistory,
@@ -283,7 +284,7 @@ async function compressFocus(
283
284
 
284
285
  // 5. 压缩内容
285
286
 
286
- // eslint-disable-next-line @typescript-eslint/init-declarations
287
+
287
288
  let compressedContent: string;
288
289
  try {
289
290
  compressedContent = compressFocusContent(oldContent, workspaceDir);
@@ -304,7 +305,7 @@ async function compressFocus(
304
305
  const newLines = newContent.split('\n').length;
305
306
  const savedLines = oldLines - newLines;
306
307
 
307
- fs.writeFileSync(focusPath, newContent, 'utf-8');
308
+ atomicWriteFileSync(focusPath, newContent);
308
309
 
309
310
  const milestoneNote = milestonesArchived
310
311
  ? isZh
@@ -410,7 +411,7 @@ function rollbackFocus(workspaceDir: string, index: number, isZh: boolean): stri
410
411
  `**状态**: ROLLBACK (from v${restoredVersion})`
411
412
  );
412
413
 
413
- fs.writeFileSync(focusPath, restoredContent, 'utf-8');
414
+ atomicWriteFileSync(focusPath, restoredContent);
414
415
 
415
416
  if (isZh) {
416
417
  return `✅ **回滚成功**
@@ -481,7 +482,7 @@ export async function handleFocusCommand(
481
482
  const isZh = (ctx.config?.language as string) === 'zh';
482
483
 
483
484
 
484
- // eslint-disable-next-line @typescript-eslint/init-declarations
485
+
485
486
  let result: string;
486
487
 
487
488
  switch (subCommand) {
@@ -26,6 +26,7 @@ import * as path from 'path';
26
26
  import * as fs from 'fs';
27
27
  import { execFileSync, spawn } from 'child_process';
28
28
  import { fileURLToPath } from 'url';
29
+ import { atomicWriteFileSync } from '../utils/io.js';
29
30
  import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
30
31
  import {
31
32
  type TrainerBackendKind,
@@ -264,8 +265,8 @@ Hardware tiers:
264
265
  if (!fs.existsSync(workspaceCheckpointsDir)) {
265
266
  fs.mkdirSync(workspaceCheckpointsDir, { recursive: true });
266
267
  }
267
- fs.writeFileSync(trainerSpecPath, JSON.stringify(spec, null, 2), 'utf-8');
268
- fs.writeFileSync(workspaceSpecPath, JSON.stringify(spec, null, 2), 'utf-8');
268
+ atomicWriteFileSync(trainerSpecPath, JSON.stringify(spec, null, 2));
269
+ atomicWriteFileSync(workspaceSpecPath, JSON.stringify(spec, null, 2));
269
270
 
270
271
  // --- Auto-run mode: execute trainer immediately ---
271
272
  // This closes the gap in the create-experiment -> trainer -> import-result chain.
@@ -282,10 +283,10 @@ Hardware tiers:
282
283
  if (!fs.existsSync(specDir)) {
283
284
  fs.mkdirSync(specDir, { recursive: true });
284
285
  }
285
- fs.writeFileSync(specPath, JSON.stringify(spec, null, 2), 'utf-8');
286
+ atomicWriteFileSync(specPath, JSON.stringify(spec, null, 2));
286
287
 
287
288
 
288
- // eslint-disable-next-line @typescript-eslint/init-declarations
289
+
289
290
  let trainerResult!: TrainingExperimentResult;
290
291
 
291
292
  try {
@@ -395,7 +396,7 @@ Hardware tiers:
395
396
  // Process trainer result (register checkpoint)
396
397
  // dry_run returns null (no checkpoint); other statuses throw on error
397
398
 
398
- // eslint-disable-next-line @typescript-eslint/init-declarations
399
+
399
400
  let processed: { checkpointId: string; checkpointRef: string } | null;
400
401
  try {
401
402
  processed = program.processResult({
@@ -537,7 +538,7 @@ Next steps:
537
538
  }
538
539
  }
539
540
 
540
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/init-declarations -- Reason: JSON.parse returns dynamic JSON - type unknown at parse time, narrowed via type narrowing below
541
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: JSON.parse returns dynamic JSON - type unknown at parse time, narrowed via type narrowing below
541
542
  let result: any;
542
543
  try {
543
544
  result = JSON.parse(resultJson);
@@ -569,7 +570,7 @@ Next steps:
569
570
  // Process the result
570
571
  const program = new TrainingProgram(workspaceDir);
571
572
 
572
- // eslint-disable-next-line @typescript-eslint/init-declarations
573
+
573
574
  let processed: { checkpointId: string; checkpointRef: string } | null;
574
575
  try {
575
576
  processed = program.processResult({
@@ -757,15 +758,15 @@ Next steps:
757
758
  }
758
759
 
759
760
  // Destructure benchmark result - delta property contains the actual delta value
760
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
761
+
761
762
  delta = benchmarkResult.delta.delta;
762
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
763
+
763
764
  baselineScore = benchmarkResult.delta.baselineScore;
764
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
765
+
765
766
  candidateScore = benchmarkResult.delta.candidateScore;
766
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
767
+
767
768
  benchmarkId = benchmarkResult.benchmarkId;
768
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
769
+
769
770
  verdict = benchmarkResult.verdict;
770
771
  } else {
771
772
  // Manual mode: require explicit delta and verdict
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { PluginCommandDefinition, PluginCommandContext, PluginCommandResult, OpenClawPluginApi } from '../openclaw-sdk.js';
9
9
  import { acquireQueueLock, EVOLUTION_QUEUE_LOCK_SUFFIX } from '../service/evolution-worker.js';
10
+ import { atomicWriteFileSync } from '../utils/io.js';
10
11
  import * as fs from 'fs';
11
12
  import * as path from 'path';
12
13
 
@@ -22,7 +23,7 @@ export const handlePdReflect: PluginCommandDefinition = {
22
23
  requireAuth: false,
23
24
  handler: async (ctx: PdReflectContext): Promise<PluginCommandResult> => {
24
25
  try {
25
- // eslint-disable-next-line @typescript-eslint/prefer-destructuring
26
+
26
27
  const workspaceDir = ctx.workspaceDir;
27
28
  if (!workspaceDir) {
28
29
  return { text: 'Cannot determine workspace directory. Ensure you are in an active workspace.', isError: true };
@@ -33,7 +34,7 @@ export const handlePdReflect: PluginCommandDefinition = {
33
34
 
34
35
  // Acquire lock before modifying queue
35
36
  const releaseLock = await acquireQueueLock(queuePath, ctx.api?.logger, EVOLUTION_QUEUE_LOCK_SUFFIX);
36
- // eslint-disable-next-line @typescript-eslint/init-declarations
37
+
37
38
  let taskId: string | undefined;
38
39
  try {
39
40
  let rawQueue: unknown[] = [];
@@ -71,7 +72,7 @@ export const handlePdReflect: PluginCommandDefinition = {
71
72
  maxRetries: 1,
72
73
  });
73
74
 
74
- fs.writeFileSync(queuePath, JSON.stringify(rawQueue, null, 2), 'utf8');
75
+ atomicWriteFileSync(queuePath, JSON.stringify(rawQueue, null, 2));
75
76
  } finally {
76
77
  releaseLock();
77
78
  }
@@ -30,6 +30,7 @@ import { WorkspaceContext } from '../core/workspace-context.js';
30
30
  import type { PluginCommandContext, PluginCommandResult } from '../openclaw-sdk.js';
31
31
  import type { Implementation } from '../types/principle-tree-schema.js';
32
32
  import { withLock } from '../utils/file-lock.js';
33
+ import { atomicWriteFileSync } from '../utils/io.js';
33
34
 
34
35
  function getAllImplementations(stateDir: string): Implementation[] {
35
36
  const ledger = loadLedger(stateDir);
@@ -227,7 +228,7 @@ function _handlePromoteImpl(options: PromoteImplOptions): PluginCommandResult {
227
228
  promotedAt: new Date().toISOString(),
228
229
  };
229
230
  withLock(eventPath, () => {
230
- fs.writeFileSync(eventPath, JSON.stringify(promotionEvent, null, 2), 'utf-8');
231
+ atomicWriteFileSync(eventPath, JSON.stringify(promotionEvent, null, 2));
231
232
  });
232
233
  try {
233
234
  refreshPrincipleLifecycle(workspaceDir, stateDir);
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { withLock } from '../utils/file-lock.js';
19
+ import { atomicWriteFileSync } from '../utils/io.js';
19
20
  import { WorkspaceContext } from '../core/workspace-context.js';
20
21
  import { refreshPrincipleLifecycle } from '../core/principle-internalization/lifecycle-refresh.js';
21
22
  import {
@@ -59,12 +60,12 @@ export function handleRollbackImplCommand(ctx: PluginCommandContext): PluginComm
59
60
  // List active
60
61
  if (subcommand === 'list' || subcommand === '') {
61
62
 
62
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
63
+
63
64
  return _handleListActiveRollback(stateDir, isZh);
64
65
  }
65
66
 
66
67
 
67
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
68
+
68
69
  return _handleRollbackImpl(workspaceDir, stateDir, implId, reason, isZh, ctx.sessionId);
69
70
  }
70
71
 
@@ -107,7 +108,7 @@ function _handleListActiveRollback(
107
108
  }
108
109
 
109
110
 
110
- // eslint-disable-next-line @typescript-eslint/max-params
111
+
111
112
  function _handleRollbackImpl(
112
113
  workspaceDir: string,
113
114
  stateDir: string,
@@ -143,7 +144,7 @@ function _handleRollbackImpl(
143
144
  transitionImplementationState(stateDir, implId, 'disabled');
144
145
 
145
146
 
146
- // eslint-disable-next-line @typescript-eslint/init-declarations
147
+
147
148
  let restoredMessage: string;
148
149
 
149
150
  if (previousActiveId && allImpls.some((i) => i.id === previousActiveId)) {
@@ -188,7 +189,7 @@ function _handleRollbackImpl(
188
189
  const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
189
190
  const rollbackPath = path.join(rollbackDir, `${rollbackTimestamp}.json`);
190
191
  withLock(rollbackPath, () => {
191
- fs.writeFileSync(rollbackPath, JSON.stringify(rollbackRecord, null, 2), 'utf-8');
192
+ atomicWriteFileSync(rollbackPath, JSON.stringify(rollbackRecord, null, 2));
192
193
  });
193
194
  try {
194
195
  refreshPrincipleLifecycle(workspaceDir, stateDir);
@@ -24,6 +24,7 @@
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
26
  import { withLock } from '../utils/file-lock.js';
27
+ import { atomicWriteFileSync } from '../utils/io.js';
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // Constants
@@ -227,9 +228,7 @@ function writeState(stateDir: string, state: ThresholdPersistenceState): void {
227
228
  }
228
229
 
229
230
  withLock(statePath, () => {
230
- const tmpPath = `${statePath}.tmp`;
231
- fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8');
232
- fs.renameSync(tmpPath, statePath);
231
+ atomicWriteFileSync(statePath, JSON.stringify(state, null, 2));
233
232
  });
234
233
  }
235
234
 
@@ -301,7 +300,7 @@ export function getEffectiveThresholds(stateDir: string): ThresholdValues {
301
300
  * @returns UpdateThresholdResult
302
301
  */
303
302
 
304
- // eslint-disable-next-line @typescript-eslint/max-params
303
+
305
304
  export function updateThresholdState(
306
305
  stateDir: string,
307
306
  thresholdName: ThresholdName,
@@ -18,6 +18,7 @@
18
18
  import * as fs from 'fs';
19
19
  import * as path from 'path';
20
20
  import { withLock } from '../utils/file-lock.js';
21
+ import { atomicWriteFileSync } from '../utils/io.js';
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Types
@@ -130,12 +131,12 @@ export function writeManifest(
130
131
  const manifestPath = path.join(assetRoot, MANIFEST_FILENAME);
131
132
  ensureDir(assetRoot);
132
133
  withLock(manifestPath, () => {
133
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
134
+ atomicWriteFileSync(manifestPath, JSON.stringify(manifest, null, 2));
134
135
  });
135
136
  }
136
137
 
137
138
 
138
- // eslint-disable-next-line @typescript-eslint/max-params
139
+
139
140
  export function writeEntrySource(
140
141
  stateDir: string,
141
142
  implId: string,
@@ -147,7 +148,7 @@ export function writeEntrySource(
147
148
  const entryPath = path.join(assetRoot, entryFile);
148
149
  ensureDir(assetRoot);
149
150
  withLock(entryPath, () => {
150
- fs.writeFileSync(entryPath, sourceCode, 'utf-8');
151
+ atomicWriteFileSync(entryPath, sourceCode);
151
152
  });
152
153
  }
153
154
 
@@ -191,7 +192,7 @@ export function loadEntrySource(stateDir: string, implId: string): string | null
191
192
  * Idempotent: calling again with the same implId will NOT overwrite an existing entry.js.
192
193
  */
193
194
 
194
- // eslint-disable-next-line @typescript-eslint/max-params
195
+
195
196
  export function createImplementationAssetDir(
196
197
  stateDir: string,
197
198
  implId: string,
@@ -219,7 +220,7 @@ export function createImplementationAssetDir(
219
220
  ensureDir(assetRoot);
220
221
  ensureDir(replaysDir);
221
222
  if (!fs.existsSync(entryPath)) {
222
- fs.writeFileSync(entryPath, entrySource, 'utf-8');
223
+ atomicWriteFileSync(entryPath, entrySource);
223
224
  }
224
225
  });
225
226
 
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { atomicWriteFileSync } from '../utils/io.js';
3
4
 
4
5
  export interface DeepReflectionSettings {
5
6
  enabled: boolean;
@@ -257,7 +258,7 @@ export class PainConfig {
257
258
  if (!fs.existsSync(dir)) {
258
259
  fs.mkdirSync(dir, { recursive: true });
259
260
  }
260
- fs.writeFileSync(this.filePath, JSON.stringify(this.settings, null, 2), 'utf8');
261
+ atomicWriteFileSync(this.filePath, JSON.stringify(this.settings, null, 2));
261
262
  console.log(`[PD:Config] Settings saved to ${this.filePath}`);
262
263
  } catch (e) {
263
264
  console.error(`[PD:Config] Failed to save settings: ${String(e)}`);
@@ -291,7 +292,7 @@ export class PainConfig {
291
292
  * Basic validation for critical settings
292
293
  */
293
294
 
294
- // eslint-disable-next-line @typescript-eslint/class-methods-use-this
295
+
295
296
  private validate(settings: PainSettings): void {
296
297
  // Ensure intervals are positive
297
298
  if (settings.intervals.worker_poll_ms < 1000) settings.intervals.worker_poll_ms = 15 * 60 * 1000;
@@ -22,6 +22,7 @@ import {
22
22
  MAX_CORRECTION_KEYWORDS,
23
23
  } from './correction-types.js';
24
24
  import { checkCooldown } from '../service/nocturnal-runtime.js';
25
+ import { atomicWriteFileSync } from '../utils/io.js';
25
26
 
26
27
  const KEYWORD_STORE_FILE = 'correction_keywords.json';
27
28
 
@@ -109,11 +110,9 @@ export function saveCorrectionKeywordStore(
109
110
  store: CorrectionKeywordStore
110
111
  ): void {
111
112
  const filePath = path.join(stateDir, KEYWORD_STORE_FILE);
112
- const tmpPath = filePath + '.tmp';
113
113
 
114
114
  fs.mkdirSync(stateDir, { recursive: true });
115
- fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), 'utf-8');
116
- fs.renameSync(tmpPath, filePath);
115
+ atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
117
116
 
118
117
  // Invalidate cache so the next read re-loads from disk (D-05)
119
118
  _correctionCueCache = null;
@@ -151,9 +150,12 @@ export class CorrectionCueLearner {
151
150
 
152
151
  /**
153
152
  * Checks whether text contains a correction cue (D-11).
153
+ * Pure read-only — does NOT modify the store.
154
154
  * Normalisation is equivalent to the original detectCorrectionCue():
155
155
  * trim → lowercase → strip punctuation
156
156
  * Returns weighted score based on keyword accuracy (D-39-03, D-39-04).
157
+ *
158
+ * To record hits/TPs, call recordHit() and recordTruePositive() separately.
157
159
  */
158
160
  match(text: string): CorrectionMatchResult {
159
161
  const normalized = text
@@ -167,19 +169,15 @@ export class CorrectionCueLearner {
167
169
  for (const keyword of this.store.keywords) {
168
170
  if (normalized.includes(keyword.term.toLowerCase())) {
169
171
  // D-39-03, D-39-04: Weighted score formula
170
- // score = weight x ((TP + 1) / (TP + FP + 2))
171
- // +2 smoothing: new keywords (TP=0, FP=0) get accuracy=0.5
172
+ // No history (tp=0, fp=0) accuracy = 1 (trust raw weight)
173
+ // Has history accuracy = tp / (tp + fp) (proportional to true positive rate)
172
174
  const tp = keyword.truePositiveCount ?? 0;
173
175
  const fp = keyword.falsePositiveCount ?? 0;
174
- const accuracy = (tp + 1) / (tp + fp + 2);
176
+ const accuracy = (tp + fp) > 0 ? tp / (tp + fp) : 1;
175
177
  const score = keyword.weight * accuracy;
176
178
 
177
179
  totalScore += score;
178
180
  matchedTerms.push(keyword.term);
179
-
180
- // Increment hitCount
181
- keyword.hitCount = (keyword.hitCount ?? 0) + 1;
182
- keyword.lastHitAt = new Date().toISOString();
183
181
  }
184
182
  }
185
183
 
@@ -199,17 +197,40 @@ export class CorrectionCueLearner {
199
197
  };
200
198
  }
201
199
 
200
+ /**
201
+ * Records a keyword hit (for hitCount/FPR tracking).
202
+ * Increments hitCount and updates lastHitAt for all matched terms.
203
+ * Intentionally does NOT flush — hitCount is best-effort analytics,
204
+ * persisted by the next recordTruePositive() or flush() call.
205
+ */
206
+ recordHits(terms: string[]): void {
207
+ for (const term of terms) {
208
+ const keywordIndex = this.store.keywords.findIndex(k => k.term.toLowerCase() === term.toLowerCase());
209
+ if (keywordIndex < 0) continue;
210
+ const keyword = this.store.keywords[keywordIndex];
211
+ this.store.keywords[keywordIndex] = {
212
+ ...keyword,
213
+ hitCount: (keyword.hitCount ?? 0) + 1,
214
+ lastHitAt: new Date().toISOString(),
215
+ };
216
+ }
217
+ }
218
+
202
219
  /**
203
220
  * Records a confirmed true positive for the given keyword term.
204
- * Increments both hitCount and truePositiveCount.
221
+ * Increments truePositiveCount atomically.
205
222
  */
206
223
  recordTruePositive(term: string): void {
207
224
  const keyword = this.store.keywords.find(k => k.term.toLowerCase() === term.toLowerCase());
208
225
  if (!keyword) return;
209
226
 
210
227
  keyword.truePositiveCount = (keyword.truePositiveCount ?? 0) + 1;
211
- keyword.hitCount = (keyword.hitCount ?? 0) + 1;
212
- keyword.lastHitAt = new Date().toISOString();
228
+
229
+ // Update in-store reference
230
+ const keywordIndex = this.store.keywords.findIndex(k => k.term.toLowerCase() === term.toLowerCase());
231
+ if (keywordIndex >= 0) {
232
+ this.store.keywords[keywordIndex] = { ...keyword };
233
+ }
213
234
 
214
235
  this.flush();
215
236
  }
@@ -217,18 +238,25 @@ export class CorrectionCueLearner {
217
238
  /**
218
239
  * Records a confirmed false positive for the given keyword term.
219
240
  * CORR-10: Decreases keyword weight by 20% (x0.8 multiplicative factor).
241
+ * D-39-17: Keywords at very low weight (<0.1) still match but contribute minimally.
220
242
  */
221
243
  recordFalsePositive(term: string): void {
222
244
  const keyword = this.store.keywords.find(k => k.term.toLowerCase() === term.toLowerCase());
223
245
  if (!keyword) return;
224
246
 
225
247
  keyword.falsePositiveCount = (keyword.falsePositiveCount ?? 0) + 1;
226
- keyword.hitCount = (keyword.hitCount ?? 0) + 1;
227
248
 
228
249
  // D-39-15: Multiplicative weight decay x0.8 on confirmed FP
229
250
  keyword.weight = Math.max(MIN_KEYWORD_WEIGHT, keyword.weight * 0.8);
230
251
  keyword.lastHitAt = new Date().toISOString();
231
252
 
253
+ // Update in-store reference
254
+ const keywordIndex = this.store.keywords.findIndex(k => k.term.toLowerCase() === term.toLowerCase());
255
+ if (keywordIndex >= 0) {
256
+ this.store.keywords[keywordIndex] = { ...keyword };
257
+ }
258
+
259
+ // D-39-16: Apply decay BEFORE flush to disk
232
260
  this.flush();
233
261
  }
234
262
 
@@ -45,7 +45,7 @@ export interface CorrectionKeywordStore {
45
45
  export interface CorrectionMatchResult {
46
46
  /** Whether any keyword matched */
47
47
  matched: boolean;
48
- /** The first matched term (empty array when no match) */
48
+ /** Matched terms (empty array when no match; may be truncated to first N items) */
49
49
  matchedTerms: string[];
50
50
  /** Weighted score (0-1) based on keyword weight and accuracy */
51
51
  score: number;
@@ -29,6 +29,7 @@
29
29
  import * as fs from 'fs';
30
30
  import * as path from 'path';
31
31
  import { withLockAsync } from '../utils/file-lock.js';
32
+ import { atomicWriteFileSync } from '../utils/io.js';
32
33
 
33
34
  const DIAGNOSTICIAN_TASKS_FILE = 'diagnostician_tasks.json';
34
35
 
@@ -83,16 +84,14 @@ export async function addDiagnosticianTask(
83
84
  const filePath = resolveTasksPath(stateDir);
84
85
  await withLockAsync(filePath, async () => {
85
86
 
86
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
87
+
87
88
  const store = readTaskStoreSync(filePath);
88
89
  store.tasks[taskId] = {
89
90
  prompt,
90
91
  createdAt: new Date().toISOString(),
91
92
  status: 'pending',
92
93
  };
93
- const tmpPath = filePath + '.tmp';
94
- fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), 'utf8');
95
- fs.renameSync(tmpPath, filePath);
94
+ atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
96
95
  });
97
96
  }
98
97
 
@@ -106,13 +105,11 @@ export async function completeDiagnosticianTask(
106
105
  ): Promise<void> {
107
106
  const filePath = resolveTasksPath(stateDir);
108
107
  await withLockAsync(filePath, async () => {
108
+
109
109
 
110
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
111
110
  const store = readTaskStoreSync(filePath);
112
111
  delete store.tasks[taskId];
113
- const tmpPath = filePath + '.tmp';
114
- fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), 'utf8');
115
- fs.renameSync(tmpPath, filePath);
112
+ atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
116
113
  });
117
114
  }
118
115
 
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { atomicWriteFileSync } from '../utils/io.js';
3
4
 
4
5
  export type RuleType = 'regex' | 'exact_match';
5
6
 
@@ -116,7 +117,7 @@ export class PainDictionary {
116
117
  }
117
118
  }
118
119
 
119
- // eslint-disable-next-line complexity -- complexity 13, refactor candidate
120
+
120
121
  match(text: string): { ruleId: string; severity: number } | undefined {
121
122
  if (shouldIgnorePainProtocolText(text)) return undefined;
122
123
 
@@ -154,7 +155,7 @@ export class PainDictionary {
154
155
  if (!fs.existsSync(this.stateDir)) {
155
156
  fs.mkdirSync(this.stateDir, { recursive: true });
156
157
  }
157
- fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf8');
158
+ atomicWriteFileSync(this.filePath, JSON.stringify(this.data, null, 2));
158
159
  } catch (e) {
159
160
  console.error('[PD] Failed to flush pain_dictionary.json:', e);
160
161
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
+ import { atomicWriteFileSync } from '../utils/io.js';
17
18
  import type {
18
19
  EmpathyKeywordStore,
19
20
  EmpathyKeywordEntry,
@@ -81,7 +82,7 @@ export function loadKeywordStore(stateDir: string, language?: 'zh' | 'en'): Empa
81
82
  if (!fs.existsSync(filePath)) {
82
83
  const store = createDefaultKeywordStore(language);
83
84
 
84
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
85
+
85
86
  saveKeywordStore(stateDir, store);
86
87
  return store;
87
88
  }
@@ -94,7 +95,7 @@ export function loadKeywordStore(stateDir: string, language?: 'zh' | 'en'): Empa
94
95
  console.warn('[PD:Empathy] Invalid keyword store format, creating default');
95
96
  const store = createDefaultKeywordStore(language);
96
97
 
97
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
98
+
98
99
  saveKeywordStore(stateDir, store);
99
100
  return store;
100
101
  }
@@ -104,7 +105,7 @@ export function loadKeywordStore(stateDir: string, language?: 'zh' | 'en'): Empa
104
105
  console.warn(`[PD:Empathy] Failed to load keyword store: ${e}`);
105
106
  const store = createDefaultKeywordStore(language);
106
107
 
107
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
108
+
108
109
  saveKeywordStore(stateDir, store);
109
110
  return store;
110
111
  }
@@ -123,7 +124,7 @@ export function saveKeywordStore(stateDir: string, store: EmpathyKeywordStore):
123
124
  }
124
125
 
125
126
  store.lastUpdated = new Date().toISOString();
126
- fs.writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf8');
127
+ atomicWriteFileSync(filePath, JSON.stringify(store, null, 2));
127
128
  }
128
129
 
129
130
  // =========================================================================