principles-disciple 1.8.0 → 1.8.2

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 (460) hide show
  1. package/ADVANCED_CONFIG_ZH.md +97 -0
  2. package/AGENT_INSTALL.md +173 -0
  3. package/AGENT_INSTALL_EN.md +173 -0
  4. package/INSTALL.md +256 -0
  5. package/SKILL.md +63 -0
  6. package/docs/COMMAND_REFERENCE.md +76 -0
  7. package/docs/COMMAND_REFERENCE_EN.md +79 -0
  8. package/esbuild.config.js +75 -0
  9. package/openclaw.plugin.json +6 -1
  10. package/package.json +13 -15
  11. package/scripts/build-web.mjs +46 -0
  12. package/scripts/install-dependencies.cjs +47 -0
  13. package/scripts/sync-plugin.mjs +802 -0
  14. package/scripts/verify-build.mjs +109 -0
  15. package/src/agents/nocturnal-dreamer.md +152 -0
  16. package/src/agents/nocturnal-philosopher.md +138 -0
  17. package/src/agents/nocturnal-reflector.md +126 -0
  18. package/src/agents/nocturnal-scribe.md +164 -0
  19. package/src/commands/capabilities.ts +85 -0
  20. package/{dist/commands/context.js → src/commands/context.ts} +78 -38
  21. package/src/commands/evolution-status.ts +146 -0
  22. package/src/commands/export.ts +111 -0
  23. package/src/commands/focus.ts +533 -0
  24. package/src/commands/nocturnal-review.ts +311 -0
  25. package/src/commands/nocturnal-rollout.ts +763 -0
  26. package/src/commands/nocturnal-train.ts +1002 -0
  27. package/{dist/commands/pain.js → src/commands/pain.ts} +68 -49
  28. package/src/commands/principle-rollback.ts +27 -0
  29. package/{dist/commands/rollback.js → src/commands/rollback.ts} +44 -12
  30. package/src/commands/samples.ts +60 -0
  31. package/src/commands/strategy.ts +38 -0
  32. package/{dist/commands/thinking-os.js → src/commands/thinking-os.ts} +59 -36
  33. package/src/commands/workflow-debug.ts +128 -0
  34. package/{dist/config/defaults/runtime.js → src/config/defaults/runtime.ts} +12 -5
  35. package/src/config/errors.ts +163 -0
  36. package/{dist/config/index.d.ts → src/config/index.ts} +2 -1
  37. package/src/constants/diagnostician.ts +66 -0
  38. package/src/constants/tools.ts +62 -0
  39. package/src/core/adaptive-thresholds.ts +476 -0
  40. package/{dist/core/config-service.js → src/core/config-service.ts} +7 -4
  41. package/{dist/core/config.js → src/core/config.ts} +158 -46
  42. package/src/core/control-ui-db.ts +435 -0
  43. package/{dist/core/detection-funnel.js → src/core/detection-funnel.ts} +36 -21
  44. package/{dist/core/detection-service.js → src/core/detection-service.ts} +7 -4
  45. package/{dist/core/dictionary-service.js → src/core/dictionary-service.ts} +7 -4
  46. package/{dist/core/dictionary.js → src/core/dictionary.ts} +57 -34
  47. package/src/core/empathy-keyword-matcher.ts +327 -0
  48. package/src/core/empathy-types.ts +218 -0
  49. package/src/core/event-log.ts +544 -0
  50. package/src/core/evolution-engine.ts +612 -0
  51. package/src/core/evolution-logger.ts +353 -0
  52. package/src/core/evolution-migration.ts +77 -0
  53. package/src/core/evolution-reducer.ts +731 -0
  54. package/src/core/evolution-types.ts +456 -0
  55. package/src/core/external-training-contract.ts +527 -0
  56. package/src/core/focus-history.ts +1458 -0
  57. package/src/core/hygiene/tracker.ts +117 -0
  58. package/{dist/core/init.js → src/core/init.ts} +39 -26
  59. package/src/core/local-worker-routing.ts +617 -0
  60. package/{dist/core/migration.js → src/core/migration.ts} +18 -11
  61. package/src/core/model-deployment-registry.ts +722 -0
  62. package/src/core/model-training-registry.ts +813 -0
  63. package/src/core/nocturnal-arbiter.ts +706 -0
  64. package/src/core/nocturnal-candidate-scoring.ts +392 -0
  65. package/src/core/nocturnal-compliance.ts +1075 -0
  66. package/src/core/nocturnal-dataset.ts +668 -0
  67. package/src/core/nocturnal-executability.ts +428 -0
  68. package/src/core/nocturnal-export.ts +390 -0
  69. package/{dist/core/nocturnal-paths.js → src/core/nocturnal-paths.ts} +49 -23
  70. package/src/core/nocturnal-trajectory-extractor.ts +484 -0
  71. package/src/core/nocturnal-trinity.ts +1384 -0
  72. package/src/core/pain.ts +122 -0
  73. package/{dist/core/path-resolver.js → src/core/path-resolver.ts} +157 -36
  74. package/{dist/core/paths.js → src/core/paths.ts} +13 -4
  75. package/src/core/principle-training-state.ts +450 -0
  76. package/src/core/profile.ts +226 -0
  77. package/src/core/promotion-gate.ts +822 -0
  78. package/{dist/core/risk-calculator.js → src/core/risk-calculator.ts} +42 -16
  79. package/{dist/core/session-tracker.js → src/core/session-tracker.ts} +185 -63
  80. package/src/core/shadow-observation-registry.ts +534 -0
  81. package/{dist/core/system-logger.js → src/core/system-logger.ts} +9 -5
  82. package/src/core/thinking-models.ts +217 -0
  83. package/src/core/training-program.ts +630 -0
  84. package/src/core/trajectory-types.ts +243 -0
  85. package/src/core/trajectory.ts +1673 -0
  86. package/{dist/core/workspace-context.js → src/core/workspace-context.ts} +57 -32
  87. package/src/hooks/bash-risk.ts +171 -0
  88. package/src/hooks/edit-verification.ts +295 -0
  89. package/src/hooks/gate-block-helper.ts +160 -0
  90. package/src/hooks/gate.ts +210 -0
  91. package/src/hooks/gfi-gate.ts +177 -0
  92. package/src/hooks/lifecycle.ts +326 -0
  93. package/{dist/hooks/llm.js → src/hooks/llm.ts} +166 -139
  94. package/src/hooks/message-sanitize.ts +45 -0
  95. package/src/hooks/pain.ts +384 -0
  96. package/src/hooks/progressive-trust-gate.ts +174 -0
  97. package/src/hooks/prompt.ts +920 -0
  98. package/src/hooks/subagent.ts +207 -0
  99. package/src/hooks/thinking-checkpoint.ts +73 -0
  100. package/src/hooks/trajectory-collector.ts +290 -0
  101. package/src/http/principles-console-route.ts +716 -0
  102. package/src/i18n/commands.ts +117 -0
  103. package/src/index.ts +694 -0
  104. package/src/service/central-database.ts +831 -0
  105. package/src/service/control-ui-query-service.ts +888 -0
  106. package/src/service/evolution-query-service.ts +405 -0
  107. package/src/service/evolution-worker.ts +1646 -0
  108. package/src/service/health-query-service.ts +836 -0
  109. package/{dist/service/nocturnal-runtime.js → src/service/nocturnal-runtime.ts} +263 -36
  110. package/src/service/nocturnal-service.ts +1015 -0
  111. package/src/service/nocturnal-target-selector.ts +532 -0
  112. package/src/service/phase3-input-filter.ts +237 -0
  113. package/src/service/runtime-summary-service.ts +757 -0
  114. package/src/service/subagent-workflow/deep-reflect-workflow-manager.ts +513 -0
  115. package/src/service/subagent-workflow/empathy-observer-workflow-manager.ts +603 -0
  116. package/src/service/subagent-workflow/index.ts +51 -0
  117. package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +856 -0
  118. package/src/service/subagent-workflow/runtime-direct-driver.ts +166 -0
  119. package/src/service/subagent-workflow/types.ts +378 -0
  120. package/src/service/subagent-workflow/workflow-store.ts +328 -0
  121. package/src/service/trajectory-service.ts +15 -0
  122. package/{dist/tools/critique-prompt.js → src/tools/critique-prompt.ts} +25 -8
  123. package/src/tools/deep-reflect.ts +349 -0
  124. package/{dist/tools/model-index.js → src/tools/model-index.ts} +33 -17
  125. package/src/types/event-types.ts +453 -0
  126. package/src/types/hygiene-types.ts +31 -0
  127. package/src/types/principle-tree-schema.ts +244 -0
  128. package/src/types/runtime-summary.ts +49 -0
  129. package/src/types.ts +74 -0
  130. package/src/utils/file-lock.ts +391 -0
  131. package/{dist/utils/glob-match.js → src/utils/glob-match.ts} +21 -20
  132. package/{dist/utils/hashing.js → src/utils/hashing.ts} +6 -4
  133. package/src/utils/io.ts +110 -0
  134. package/{dist/utils/nlp.js → src/utils/nlp.ts} +19 -12
  135. package/{dist/utils/plugin-logger.js → src/utils/plugin-logger.ts} +33 -8
  136. package/src/utils/subagent-probe.ts +94 -0
  137. package/templates/langs/zh/skills/pd-diagnostician/SKILL.md +70 -1
  138. package/templates/pain_settings.json +2 -1
  139. package/tests/README.md +120 -0
  140. package/tests/build-artifacts.test.ts +111 -0
  141. package/tests/commands/evolution-status.test.ts +222 -0
  142. package/tests/commands/evolver.test.ts +22 -0
  143. package/tests/commands/export.test.ts +78 -0
  144. package/tests/commands/nocturnal-review.test.ts +448 -0
  145. package/tests/commands/nocturnal-train.test.ts +97 -0
  146. package/tests/commands/pain.test.ts +108 -0
  147. package/tests/commands/samples.test.ts +65 -0
  148. package/tests/commands/strategy.test.ts +34 -0
  149. package/tests/commands/thinking-os.test.ts +88 -0
  150. package/tests/core/adaptive-thresholds.test.ts +261 -0
  151. package/tests/core/config-service.test.ts +89 -0
  152. package/tests/core/config.test.ts +90 -0
  153. package/tests/core/control-ui-db.test.ts +75 -0
  154. package/tests/core/core-template-guidance.test.ts +21 -0
  155. package/tests/core/detection-funnel.test.ts +63 -0
  156. package/tests/core/detection-service.test.ts +50 -0
  157. package/tests/core/dictionary-service.test.ts +116 -0
  158. package/tests/core/dictionary.test.ts +168 -0
  159. package/tests/core/empathy-keyword-matcher.test.ts +209 -0
  160. package/tests/core/event-log.test.ts +181 -0
  161. package/tests/core/evolution-e2e.test.ts +58 -0
  162. package/tests/core/evolution-engine-gate-integration.test.ts +543 -0
  163. package/tests/core/evolution-engine.test.ts +562 -0
  164. package/tests/core/evolution-logger.test.ts +148 -0
  165. package/tests/core/evolution-migration.test.ts +50 -0
  166. package/tests/core/evolution-paths.test.ts +21 -0
  167. package/tests/core/evolution-reducer.detector-metadata.test.ts +602 -0
  168. package/tests/core/evolution-reducer.test.ts +180 -0
  169. package/tests/core/evolution-types-loop.test.ts +48 -0
  170. package/tests/core/evolution-user-stories.e2e.test.ts +249 -0
  171. package/tests/core/external-training-contract.test.ts +463 -0
  172. package/tests/core/focus-history.test.ts +682 -0
  173. package/tests/core/init-flatten.test.ts +69 -0
  174. package/tests/core/init-refactor.test.ts +87 -0
  175. package/tests/core/init-v1.3.test.ts +46 -0
  176. package/tests/core/init.test.ts +190 -0
  177. package/tests/core/local-worker-routing.test.ts +757 -0
  178. package/tests/core/migration.test.ts +84 -0
  179. package/tests/core/model-deployment-registry.test.ts +845 -0
  180. package/tests/core/model-training-registry.test.ts +889 -0
  181. package/tests/core/nocturnal-arbiter.test.ts +494 -0
  182. package/tests/core/nocturnal-candidate-scoring.test.ts +400 -0
  183. package/tests/core/nocturnal-compliance.test.ts +646 -0
  184. package/tests/core/nocturnal-dataset.test.ts +892 -0
  185. package/tests/core/nocturnal-executability.test.ts +357 -0
  186. package/tests/core/nocturnal-export.test.ts +462 -0
  187. package/tests/core/nocturnal-reviewed-subset-comparison.test.ts +428 -0
  188. package/tests/core/nocturnal-trajectory-extractor.test.ts +634 -0
  189. package/tests/core/nocturnal-trinity.test.ts +953 -0
  190. package/tests/core/pain.test.ts +33 -0
  191. package/tests/core/path-resolver.test.ts +57 -0
  192. package/tests/core/paths-refactor.test.ts +42 -0
  193. package/tests/core/phase7-rollout-integration.test.ts +477 -0
  194. package/tests/core/principle-training-state.test.ts +712 -0
  195. package/tests/core/profile.test.ts +56 -0
  196. package/tests/core/promotion-gate.test.ts +556 -0
  197. package/tests/core/risk-calculator.test.ts +168 -0
  198. package/tests/core/session-tracker.test.ts +191 -0
  199. package/tests/core/training-program.test.ts +472 -0
  200. package/tests/core/trajectory.test.ts +265 -0
  201. package/tests/core/workspace-context-factory.test.ts +18 -0
  202. package/tests/core/workspace-context.test.ts +134 -0
  203. package/tests/fixtures/nocturnal-reviewed-subset.json +183 -0
  204. package/tests/fixtures/production-compatibility.test.ts +147 -0
  205. package/tests/fixtures/production-mock-generator.ts +282 -0
  206. package/tests/hooks/bash-risk-integration.test.ts +137 -0
  207. package/tests/hooks/bash-risk.test.ts +81 -0
  208. package/tests/hooks/edit-verification.test.ts +678 -0
  209. package/tests/hooks/gate-edit-verification-p1.test.ts +632 -0
  210. package/tests/hooks/gate-edit-verification.test.ts +435 -0
  211. package/tests/hooks/gate-pipeline-integration.test.ts +404 -0
  212. package/tests/hooks/gate.test.ts +271 -0
  213. package/tests/hooks/gfi-gate-unit.test.ts +422 -0
  214. package/tests/hooks/gfi-gate.test.ts +669 -0
  215. package/tests/hooks/lifecycle.test.ts +248 -0
  216. package/tests/hooks/llm.test.ts +308 -0
  217. package/tests/hooks/message-sanitize.test.ts +36 -0
  218. package/tests/hooks/pain.test.ts +141 -0
  219. package/tests/hooks/progressive-trust-gate.test.ts +277 -0
  220. package/tests/hooks/prompt.test.ts +1411 -0
  221. package/tests/hooks/subagent.test.ts +467 -0
  222. package/tests/hooks/thinking-gate.test.ts +313 -0
  223. package/tests/http/principles-console-route.test.ts +140 -0
  224. package/tests/hygiene-tracker.test.ts +77 -0
  225. package/tests/index.integration.test.ts +179 -0
  226. package/tests/index.shadow-routing.integration.test.ts +140 -0
  227. package/tests/index.test.ts +9 -0
  228. package/tests/integration/empathy-workflow-integration.test.ts +627 -0
  229. package/tests/service/control-ui-query-service.test.ts +121 -0
  230. package/tests/service/empathy-observer-workflow-manager.test.ts +176 -0
  231. package/tests/service/evolution-worker.test.ts +585 -0
  232. package/tests/service/nocturnal-runtime.test.ts +470 -0
  233. package/tests/service/nocturnal-service.test.ts +577 -0
  234. package/tests/service/nocturnal-target-selector.test.ts +615 -0
  235. package/tests/service/nocturnal-workflow-manager.test.ts +439 -0
  236. package/tests/service/phase3-input-filter.test.ts +289 -0
  237. package/tests/service/runtime-summary-service.test.ts +919 -0
  238. package/tests/task-compliance.test.ts +166 -0
  239. package/tests/test-utils.ts +48 -0
  240. package/tests/tools/critique-prompt.test.ts +260 -0
  241. package/tests/tools/deep-reflect.test.ts +232 -0
  242. package/tests/tools/model-index.test.ts +246 -0
  243. package/tests/ui/app.test.tsx +114 -0
  244. package/tests/utils/file-lock.test.ts +407 -0
  245. package/tests/utils/hashing.test.ts +32 -0
  246. package/tests/utils/io.test.ts +39 -0
  247. package/tests/utils/nlp.test.ts +53 -0
  248. package/tests/utils/plugin-logger.test.ts +156 -0
  249. package/tsconfig.json +16 -0
  250. package/tsconfig.tsbuildinfo +1 -0
  251. package/ui/src/App.tsx +45 -0
  252. package/ui/src/api.ts +216 -0
  253. package/ui/src/charts.tsx +586 -0
  254. package/ui/src/components/ErrorState.tsx +6 -0
  255. package/ui/src/components/Loading.tsx +13 -0
  256. package/ui/src/components/ProtectedRoute.tsx +12 -0
  257. package/ui/src/components/Shell.tsx +91 -0
  258. package/ui/src/components/WorkspaceConfig.tsx +146 -0
  259. package/ui/src/components/index.ts +5 -0
  260. package/ui/src/context/auth.tsx +80 -0
  261. package/ui/src/context/theme.tsx +66 -0
  262. package/ui/src/hooks/useAutoRefresh.ts +39 -0
  263. package/ui/src/i18n/ui.ts +363 -0
  264. package/ui/src/main.tsx +16 -0
  265. package/ui/src/pages/EvolutionPage.tsx +352 -0
  266. package/ui/src/pages/FeedbackPage.tsx +140 -0
  267. package/ui/src/pages/GateMonitorPage.tsx +136 -0
  268. package/ui/src/pages/LoginPage.tsx +88 -0
  269. package/ui/src/pages/OverviewPage.tsx +238 -0
  270. package/ui/src/pages/SamplesPage.tsx +174 -0
  271. package/ui/src/pages/ThinkingModelsPage.tsx +127 -0
  272. package/ui/src/styles.css +1661 -0
  273. package/ui/src/types.ts +368 -0
  274. package/ui/src/utils/format.ts +15 -0
  275. package/vitest.config.ts +23 -0
  276. package/dist/commands/capabilities.d.ts +0 -3
  277. package/dist/commands/capabilities.js +0 -73
  278. package/dist/commands/context.d.ts +0 -5
  279. package/dist/commands/evolution-status.d.ts +0 -4
  280. package/dist/commands/evolution-status.js +0 -117
  281. package/dist/commands/evolver.d.ts +0 -9
  282. package/dist/commands/evolver.js +0 -26
  283. package/dist/commands/export.d.ts +0 -2
  284. package/dist/commands/export.js +0 -98
  285. package/dist/commands/focus.d.ts +0 -14
  286. package/dist/commands/focus.js +0 -457
  287. package/dist/commands/nocturnal-review.d.ts +0 -24
  288. package/dist/commands/nocturnal-review.js +0 -265
  289. package/dist/commands/nocturnal-rollout.d.ts +0 -27
  290. package/dist/commands/nocturnal-rollout.js +0 -671
  291. package/dist/commands/nocturnal-train.d.ts +0 -25
  292. package/dist/commands/nocturnal-train.js +0 -919
  293. package/dist/commands/pain.d.ts +0 -5
  294. package/dist/commands/principle-rollback.d.ts +0 -4
  295. package/dist/commands/principle-rollback.js +0 -22
  296. package/dist/commands/rollback.d.ts +0 -19
  297. package/dist/commands/samples.d.ts +0 -2
  298. package/dist/commands/samples.js +0 -55
  299. package/dist/commands/strategy.d.ts +0 -3
  300. package/dist/commands/strategy.js +0 -29
  301. package/dist/commands/thinking-os.d.ts +0 -2
  302. package/dist/config/defaults/runtime.d.ts +0 -40
  303. package/dist/config/errors.d.ts +0 -84
  304. package/dist/config/errors.js +0 -94
  305. package/dist/config/index.js +0 -7
  306. package/dist/constants/diagnostician.d.ts +0 -12
  307. package/dist/constants/diagnostician.js +0 -56
  308. package/dist/constants/tools.d.ts +0 -17
  309. package/dist/constants/tools.js +0 -54
  310. package/dist/core/adaptive-thresholds.d.ts +0 -186
  311. package/dist/core/adaptive-thresholds.js +0 -300
  312. package/dist/core/config-service.d.ts +0 -15
  313. package/dist/core/config.d.ts +0 -127
  314. package/dist/core/control-ui-db.d.ts +0 -95
  315. package/dist/core/control-ui-db.js +0 -292
  316. package/dist/core/detection-funnel.d.ts +0 -33
  317. package/dist/core/detection-service.d.ts +0 -15
  318. package/dist/core/dictionary-service.d.ts +0 -15
  319. package/dist/core/dictionary.d.ts +0 -38
  320. package/dist/core/event-log.d.ts +0 -82
  321. package/dist/core/event-log.js +0 -463
  322. package/dist/core/evolution-engine.d.ts +0 -118
  323. package/dist/core/evolution-engine.js +0 -464
  324. package/dist/core/evolution-logger.d.ts +0 -137
  325. package/dist/core/evolution-logger.js +0 -256
  326. package/dist/core/evolution-migration.d.ts +0 -5
  327. package/dist/core/evolution-migration.js +0 -65
  328. package/dist/core/evolution-reducer.d.ts +0 -98
  329. package/dist/core/evolution-reducer.js +0 -465
  330. package/dist/core/evolution-types.d.ts +0 -287
  331. package/dist/core/evolution-types.js +0 -78
  332. package/dist/core/external-training-contract.d.ts +0 -276
  333. package/dist/core/external-training-contract.js +0 -269
  334. package/dist/core/focus-history.d.ts +0 -210
  335. package/dist/core/focus-history.js +0 -1185
  336. package/dist/core/hygiene/tracker.d.ts +0 -22
  337. package/dist/core/hygiene/tracker.js +0 -106
  338. package/dist/core/init.d.ts +0 -12
  339. package/dist/core/local-worker-routing.d.ts +0 -175
  340. package/dist/core/local-worker-routing.js +0 -525
  341. package/dist/core/migration.d.ts +0 -6
  342. package/dist/core/model-deployment-registry.d.ts +0 -218
  343. package/dist/core/model-deployment-registry.js +0 -503
  344. package/dist/core/model-training-registry.d.ts +0 -295
  345. package/dist/core/model-training-registry.js +0 -475
  346. package/dist/core/nocturnal-arbiter.d.ts +0 -159
  347. package/dist/core/nocturnal-arbiter.js +0 -534
  348. package/dist/core/nocturnal-candidate-scoring.d.ts +0 -137
  349. package/dist/core/nocturnal-candidate-scoring.js +0 -266
  350. package/dist/core/nocturnal-compliance.d.ts +0 -175
  351. package/dist/core/nocturnal-compliance.js +0 -824
  352. package/dist/core/nocturnal-dataset.d.ts +0 -224
  353. package/dist/core/nocturnal-dataset.js +0 -443
  354. package/dist/core/nocturnal-executability.d.ts +0 -85
  355. package/dist/core/nocturnal-executability.js +0 -331
  356. package/dist/core/nocturnal-export.d.ts +0 -124
  357. package/dist/core/nocturnal-export.js +0 -275
  358. package/dist/core/nocturnal-paths.d.ts +0 -124
  359. package/dist/core/nocturnal-trajectory-extractor.d.ts +0 -242
  360. package/dist/core/nocturnal-trajectory-extractor.js +0 -307
  361. package/dist/core/nocturnal-trinity.d.ts +0 -311
  362. package/dist/core/nocturnal-trinity.js +0 -880
  363. package/dist/core/pain.d.ts +0 -4
  364. package/dist/core/pain.js +0 -70
  365. package/dist/core/path-resolver.d.ts +0 -46
  366. package/dist/core/paths.d.ts +0 -65
  367. package/dist/core/principle-training-state.d.ts +0 -121
  368. package/dist/core/principle-training-state.js +0 -321
  369. package/dist/core/profile.d.ts +0 -62
  370. package/dist/core/profile.js +0 -210
  371. package/dist/core/promotion-gate.d.ts +0 -238
  372. package/dist/core/promotion-gate.js +0 -529
  373. package/dist/core/risk-calculator.d.ts +0 -22
  374. package/dist/core/session-tracker.d.ts +0 -99
  375. package/dist/core/shadow-observation-registry.d.ts +0 -217
  376. package/dist/core/shadow-observation-registry.js +0 -308
  377. package/dist/core/system-logger.d.ts +0 -8
  378. package/dist/core/thinking-models.d.ts +0 -38
  379. package/dist/core/thinking-models.js +0 -170
  380. package/dist/core/training-program.d.ts +0 -233
  381. package/dist/core/training-program.js +0 -433
  382. package/dist/core/trajectory.d.ts +0 -411
  383. package/dist/core/trajectory.js +0 -1307
  384. package/dist/core/workspace-context.d.ts +0 -71
  385. package/dist/hooks/bash-risk.d.ts +0 -57
  386. package/dist/hooks/bash-risk.js +0 -137
  387. package/dist/hooks/edit-verification.d.ts +0 -62
  388. package/dist/hooks/edit-verification.js +0 -256
  389. package/dist/hooks/gate-block-helper.d.ts +0 -44
  390. package/dist/hooks/gate-block-helper.js +0 -119
  391. package/dist/hooks/gate.d.ts +0 -24
  392. package/dist/hooks/gate.js +0 -173
  393. package/dist/hooks/gfi-gate.d.ts +0 -40
  394. package/dist/hooks/gfi-gate.js +0 -113
  395. package/dist/hooks/lifecycle.d.ts +0 -5
  396. package/dist/hooks/lifecycle.js +0 -284
  397. package/dist/hooks/llm.d.ts +0 -12
  398. package/dist/hooks/message-sanitize.d.ts +0 -3
  399. package/dist/hooks/message-sanitize.js +0 -37
  400. package/dist/hooks/pain.d.ts +0 -5
  401. package/dist/hooks/pain.js +0 -301
  402. package/dist/hooks/progressive-trust-gate.d.ts +0 -51
  403. package/dist/hooks/progressive-trust-gate.js +0 -89
  404. package/dist/hooks/prompt.d.ts +0 -47
  405. package/dist/hooks/prompt.js +0 -884
  406. package/dist/hooks/subagent.d.ts +0 -10
  407. package/dist/hooks/subagent.js +0 -387
  408. package/dist/hooks/thinking-checkpoint.d.ts +0 -37
  409. package/dist/hooks/thinking-checkpoint.js +0 -51
  410. package/dist/hooks/trajectory-collector.d.ts +0 -32
  411. package/dist/hooks/trajectory-collector.js +0 -256
  412. package/dist/http/principles-console-route.d.ts +0 -9
  413. package/dist/http/principles-console-route.js +0 -567
  414. package/dist/i18n/commands.d.ts +0 -26
  415. package/dist/i18n/commands.js +0 -116
  416. package/dist/index.d.ts +0 -7
  417. package/dist/index.js +0 -581
  418. package/dist/service/central-database.d.ts +0 -104
  419. package/dist/service/central-database.js +0 -649
  420. package/dist/service/control-ui-query-service.d.ts +0 -221
  421. package/dist/service/control-ui-query-service.js +0 -543
  422. package/dist/service/empathy-observer-manager.d.ts +0 -52
  423. package/dist/service/empathy-observer-manager.js +0 -229
  424. package/dist/service/evolution-query-service.d.ts +0 -155
  425. package/dist/service/evolution-query-service.js +0 -258
  426. package/dist/service/evolution-worker.d.ts +0 -101
  427. package/dist/service/evolution-worker.js +0 -974
  428. package/dist/service/nocturnal-runtime.d.ts +0 -183
  429. package/dist/service/nocturnal-service.d.ts +0 -163
  430. package/dist/service/nocturnal-service.js +0 -787
  431. package/dist/service/nocturnal-target-selector.d.ts +0 -145
  432. package/dist/service/nocturnal-target-selector.js +0 -315
  433. package/dist/service/phase3-input-filter.d.ts +0 -73
  434. package/dist/service/phase3-input-filter.js +0 -172
  435. package/dist/service/runtime-summary-service.d.ts +0 -122
  436. package/dist/service/runtime-summary-service.js +0 -485
  437. package/dist/service/trajectory-service.d.ts +0 -2
  438. package/dist/service/trajectory-service.js +0 -15
  439. package/dist/tools/critique-prompt.d.ts +0 -14
  440. package/dist/tools/deep-reflect.d.ts +0 -39
  441. package/dist/tools/deep-reflect.js +0 -350
  442. package/dist/tools/model-index.d.ts +0 -9
  443. package/dist/types/event-types.d.ts +0 -306
  444. package/dist/types/event-types.js +0 -106
  445. package/dist/types/hygiene-types.d.ts +0 -20
  446. package/dist/types/hygiene-types.js +0 -12
  447. package/dist/types/runtime-summary.d.ts +0 -47
  448. package/dist/types/runtime-summary.js +0 -1
  449. package/dist/types.d.ts +0 -50
  450. package/dist/types.js +0 -22
  451. package/dist/utils/file-lock.d.ts +0 -71
  452. package/dist/utils/file-lock.js +0 -309
  453. package/dist/utils/glob-match.d.ts +0 -28
  454. package/dist/utils/hashing.d.ts +0 -9
  455. package/dist/utils/io.d.ts +0 -6
  456. package/dist/utils/io.js +0 -106
  457. package/dist/utils/nlp.d.ts +0 -9
  458. package/dist/utils/plugin-logger.d.ts +0 -39
  459. package/dist/utils/subagent-probe.d.ts +0 -34
  460. package/dist/utils/subagent-probe.js +0 -81
@@ -0,0 +1,1646 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { createHash } from 'crypto';
4
+ import type { OpenClawPluginServiceContext, OpenClawPluginApi, PluginLogger } from '../openclaw-sdk.js';
5
+ import { DictionaryService } from '../core/dictionary-service.js';
6
+ import { DetectionService } from '../core/detection-service.js';
7
+ import { ensureStateTemplates } from '../core/init.js';
8
+ import { extractCommonSubstring } from '../utils/nlp.js';
9
+ import { SystemLogger } from '../core/system-logger.js';
10
+ import { WorkspaceContext } from '../core/workspace-context.js';
11
+ import { EventLog } from '../core/event-log.js';
12
+ import { initPersistence, flushAllSessions } from '../core/session-tracker.js';
13
+ import { acquireLockAsync, releaseLock, type LockContext } from '../utils/file-lock.js';
14
+ import { getEvolutionLogger, type EvolutionStage } from '../core/evolution-logger.js';
15
+ import type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
16
+ export type { TaskKind, TaskPriority } from '../core/trajectory-types.js';
17
+ import { LockUnavailableError } from '../config/index.js';
18
+ import { checkWorkspaceIdle, checkCooldown } from './nocturnal-runtime.js';
19
+ import { WorkflowStore } from './subagent-workflow/workflow-store.js';
20
+ import { EmpathyObserverWorkflowManager } from './subagent-workflow/empathy-observer-workflow-manager.js';
21
+ import { DeepReflectWorkflowManager } from './subagent-workflow/deep-reflect-workflow-manager.js';
22
+ import { NocturnalWorkflowManager, nocturnalWorkflowSpec } from './subagent-workflow/nocturnal-workflow-manager.js';
23
+
24
+ const WORKFLOW_TTL_MS = 5 * 60 * 1000; // 5 minutes default TTL for helper workflows
25
+ import { OpenClawTrinityRuntimeAdapter } from '../core/nocturnal-trinity.js';
26
+
27
+ let timeoutId: NodeJS.Timeout | null = null;
28
+
29
+ /**
30
+ * Queue V2 Schema - Supports multiple task kinds while preserving pain_diagnosis semantics.
31
+ *
32
+ * taskKind semantics:
33
+ * - pain_diagnosis: User-adjacent, triggers HEARTBEAT, injects into user prompts
34
+ * - sleep_reflection: Background-only, never injects into user prompts, no HEARTBEAT
35
+ *
36
+ * Old queue items (without taskKind) are migrated to pain_diagnosis for compatibility.
37
+ */
38
+ export type QueueStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'canceled';
39
+ export type TaskResolution = 'marker_detected' | 'auto_completed_timeout' | 'failed_max_retries' | 'canceled' | 'late_marker_principle_created' | 'late_marker_no_principle';
40
+
41
+ /**
42
+ * Recent pain context attached to sleep_reflection tasks.
43
+ * Carries explicit recent pain signal metadata without being a separate task kind.
44
+ * Used by NocturnalTargetSelector for ranking bias and context enrichment.
45
+ */
46
+ export interface RecentPainContext {
47
+ /** Most recent unresolved pain event */
48
+ mostRecent: {
49
+ score: number;
50
+ source: string;
51
+ reason: string;
52
+ timestamp: string;
53
+ } | null;
54
+ /** Count of pain events in the recent window (for signal strength) */
55
+ recentPainCount: number;
56
+ /** Highest pain score in the recent window */
57
+ recentMaxPainScore: number;
58
+ }
59
+
60
+ export interface EvolutionQueueItem {
61
+ // Core identity
62
+ id: string;
63
+ taskKind: TaskKind; // V2: distinguishes task types
64
+ priority: TaskPriority; // V2: scheduling priority
65
+ source: string;
66
+ traceId?: string; // Trace ID for linking events across the evolution lifecycle
67
+
68
+ // Legacy fields (still used for pain_diagnosis)
69
+ task?: string;
70
+ score: number;
71
+ reason: string;
72
+ timestamp: string;
73
+ enqueued_at?: string;
74
+ started_at?: string;
75
+ completed_at?: string;
76
+ assigned_session_key?: string;
77
+ trigger_text_preview?: string;
78
+ status: QueueStatus; // V2: includes 'failed' and 'canceled'
79
+ resolution?: TaskResolution;
80
+ session_id?: string;
81
+ agent_id?: string;
82
+
83
+ // V2 retry support
84
+ retryCount: number; // V2: number of retry attempts
85
+ maxRetries: number; // V2: maximum retry attempts allowed
86
+ lastError?: string; // V2: last error message if failed
87
+
88
+ // V2 result reference
89
+ resultRef?: string; // V2: reference to result artifact
90
+
91
+ // V2: Recent pain context for sleep_reflection tasks
92
+ // Attaches explicit recent pain signal without merging task kinds.
93
+ // Used by target selector for ranking bias and context enrichment.
94
+ recentPainContext?: RecentPainContext;
95
+ }
96
+
97
+ /**
98
+ * Legacy queue item shape (pre-V2) for migration compatibility.
99
+ * These items lack taskKind, priority, retryCount, maxRetries, lastError fields.
100
+ */
101
+ interface LegacyEvolutionQueueItem {
102
+ id: string;
103
+ task?: string;
104
+ score: number;
105
+ source: string;
106
+ reason: string;
107
+ timestamp: string;
108
+ enqueued_at?: string;
109
+ started_at?: string;
110
+ completed_at?: string;
111
+ assigned_session_key?: string;
112
+ trigger_text_preview?: string;
113
+ status?: string;
114
+ resolution?: string;
115
+ session_id?: string;
116
+ agent_id?: string;
117
+ traceId?: string;
118
+ taskKind?: string;
119
+ priority?: string;
120
+ retryCount?: number;
121
+ maxRetries?: number;
122
+ lastError?: string;
123
+ resultRef?: string;
124
+ }
125
+
126
+ interface PainCandidateEntry {
127
+ count: number;
128
+ status: string;
129
+ firstSeen: string;
130
+ lastSeen: string;
131
+ samples: string[];
132
+ }
133
+
134
+ /**
135
+ * Default values for new V2 fields when migrating legacy items.
136
+ */
137
+ const DEFAULT_TASK_KIND: TaskKind = 'pain_diagnosis';
138
+ const DEFAULT_PRIORITY: TaskPriority = 'medium';
139
+ const DEFAULT_MAX_RETRIES = 3;
140
+
141
+ /**
142
+ * Migrate a legacy queue item to V2 schema.
143
+ * Old items without taskKind are assumed to be pain_diagnosis for backward compatibility.
144
+ */
145
+ function migrateToV2(item: LegacyEvolutionQueueItem): EvolutionQueueItem {
146
+ return {
147
+ id: item.id,
148
+ taskKind: (item.taskKind as TaskKind) || DEFAULT_TASK_KIND,
149
+ priority: (item.priority as TaskPriority) || DEFAULT_PRIORITY,
150
+ source: item.source,
151
+ traceId: item.traceId,
152
+ task: item.task,
153
+ score: item.score,
154
+ reason: item.reason,
155
+ timestamp: item.timestamp,
156
+ enqueued_at: item.enqueued_at,
157
+ started_at: item.started_at,
158
+ completed_at: item.completed_at,
159
+ assigned_session_key: item.assigned_session_key,
160
+ trigger_text_preview: item.trigger_text_preview,
161
+ status: (item.status as QueueStatus) || 'pending',
162
+ resolution: item.resolution as TaskResolution | undefined,
163
+ session_id: item.session_id,
164
+ agent_id: item.agent_id,
165
+ retryCount: item.retryCount || 0,
166
+ maxRetries: item.maxRetries || DEFAULT_MAX_RETRIES,
167
+ lastError: item.lastError,
168
+ resultRef: item.resultRef,
169
+ };
170
+ }
171
+
172
+ type RawQueueItem = Record<string, unknown>;
173
+
174
+ /**
175
+ * Check if an item is a legacy (pre-V2) queue item.
176
+ */
177
+ function isLegacyQueueItem(item: RawQueueItem): boolean {
178
+ return item && typeof item === 'object' && !('taskKind' in item);
179
+ }
180
+
181
+ /**
182
+ * Migrate entire queue to V2 schema if needed.
183
+ * Returns a new array with all items migrated to V2 format.
184
+ */
185
+ function migrateQueueToV2(queue: RawQueueItem[]): EvolutionQueueItem[] {
186
+ return queue.map(item => isLegacyQueueItem(item) ? migrateToV2(item as unknown as LegacyEvolutionQueueItem) : item as unknown as EvolutionQueueItem);
187
+ }
188
+
189
+ const PAIN_QUEUE_DEDUP_WINDOW_MS = 30 * 60 * 1000;
190
+
191
+ // P0 fix: File lock constants and helper for queue operations (prevents TOCTOU race)
192
+ export const EVOLUTION_QUEUE_LOCK_SUFFIX = '.lock';
193
+ export const PAIN_CANDIDATES_LOCK_SUFFIX = '.candidates.lock';
194
+ export const LOCK_MAX_RETRIES = 50;
195
+ export const LOCK_RETRY_DELAY_MS = 50;
196
+ export const LOCK_STALE_MS = 30_000;
197
+ const PAIN_CANDIDATE_MAX_SAMPLES = 5;
198
+ const PAIN_CANDIDATE_SAMPLE_LEN = 1000;
199
+ const PAIN_CANDIDATE_FINGERPRINT_HEAD_LEN = 160;
200
+ const PAIN_CANDIDATE_FINGERPRINT_TAIL_LEN = 80;
201
+
202
+ export function createEvolutionTaskId(
203
+ source: string,
204
+ score: number,
205
+ preview: string,
206
+ reason: string,
207
+ now: number
208
+ ): string {
209
+ // Keep ids short for prompt injection, but include enough entropy to avoid
210
+ // collisions between different pain events that share the same source/score/preview.
211
+ return createHash('md5')
212
+ .update(`${source}:${score}:${preview}:${reason}:${now}`)
213
+ .digest('hex')
214
+ .substring(0, 8);
215
+ }
216
+
217
+ function normalizePainCandidateText(text: string): string {
218
+ return text.replace(/\s+/g, ' ').trim();
219
+ }
220
+
221
+ export function shouldTrackPainCandidate(text: string): boolean {
222
+ const normalized = normalizePainCandidateText(text);
223
+ if (!normalized) return false;
224
+ if (normalized === 'NO_REPLY') return false;
225
+
226
+ // Skip empathy observer payloads: they are classifier telemetry, not user/system pain patterns.
227
+ if (
228
+ normalized.startsWith('{')
229
+ && normalized.endsWith('}')
230
+ && normalized.includes('"damageDetected"')
231
+ && normalized.includes('"severity"')
232
+ && normalized.includes('"confidence"')
233
+ ) {
234
+ return false;
235
+ }
236
+
237
+ return true;
238
+ }
239
+
240
+ export function createPainCandidateFingerprint(text: string): string {
241
+ const normalized = normalizePainCandidateText(text);
242
+ const head = normalized.substring(0, PAIN_CANDIDATE_FINGERPRINT_HEAD_LEN);
243
+ const tail = normalized.slice(-PAIN_CANDIDATE_FINGERPRINT_TAIL_LEN);
244
+
245
+ return createHash('md5')
246
+ .update(`${normalized.length}:${head}:${tail}`)
247
+ .digest('hex')
248
+ .substring(0, 8);
249
+ }
250
+
251
+ export function summarizePainCandidateSample(text: string): string {
252
+ return normalizePainCandidateText(text).substring(0, PAIN_CANDIDATE_SAMPLE_LEN);
253
+ }
254
+
255
+ function isPendingPainCandidate(status: string | undefined): boolean {
256
+ return status === undefined || status === 'pending';
257
+ }
258
+
259
+ export async function acquireQueueLock(resourcePath: string, logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined, lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX): Promise<() => void> {
260
+ try {
261
+ const ctx: LockContext = await acquireLockAsync(resourcePath, {
262
+ lockSuffix,
263
+ maxRetries: LOCK_MAX_RETRIES,
264
+ baseRetryDelayMs: LOCK_RETRY_DELAY_MS,
265
+ lockStaleMs: LOCK_STALE_MS,
266
+ });
267
+ return () => releaseLock(ctx);
268
+ } catch (error: unknown) {
269
+ const warn = logger?.warn;
270
+ warn?.(`[PD:EvolutionWorker] Failed to acquire lock for ${resourcePath}: ${String(error)}`);
271
+ throw error;
272
+ }
273
+ }
274
+
275
+ async function requireQueueLock(resourcePath: string, logger: PluginLogger | { warn?: (message: string) => void; info?: (message: string) => void } | undefined, scope: string, lockSuffix: string = EVOLUTION_QUEUE_LOCK_SUFFIX): Promise<() => void> {
276
+ try {
277
+ return await acquireQueueLock(resourcePath, logger, lockSuffix);
278
+ } catch (err) {
279
+ throw new LockUnavailableError(resourcePath, scope, { cause: err });
280
+ }
281
+ }
282
+
283
+ export function extractEvolutionTaskId(task: string): string | null {
284
+ if (!task) return null;
285
+ const match = task.match(/\[ID:\s*([A-Za-z0-9_-]+)\]/);
286
+ return match?.[1] || null;
287
+ }
288
+
289
+ function findRecentDuplicateTask(
290
+ queue: EvolutionQueueItem[],
291
+ source: string,
292
+ preview: string,
293
+ now: number,
294
+ reason?: string
295
+ ): EvolutionQueueItem | undefined {
296
+ const key = normalizePainDedupKey(source, preview, reason);
297
+ return queue.find((task) => {
298
+ if (task.status === 'completed') return false;
299
+ const taskTime = new Date(task.enqueued_at || task.timestamp).getTime();
300
+ if (!Number.isFinite(taskTime) || (now - taskTime) > PAIN_QUEUE_DEDUP_WINDOW_MS) return false;
301
+ return normalizePainDedupKey(task.source, task.trigger_text_preview || '', task.reason) === key;
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Purge stale failed tasks from the queue.
307
+ * Failed tasks older than the threshold are noise — they won't auto-recover
308
+ * and they bloat the queue, slowing every cycle.
309
+ *
310
+ * Called at the start of each cycle to keep the queue lean.
311
+ */
312
+ const STALE_FAILED_TASK_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
313
+
314
+ export function purgeStaleFailedTasks(
315
+ queue: EvolutionQueueItem[],
316
+ logger: PluginLogger,
317
+ ): { purged: number; remaining: number; byReason: Record<string, number> } {
318
+ const beforeCount = queue.length;
319
+ const cutoff = Date.now() - STALE_FAILED_TASK_MAX_AGE_MS;
320
+ const byReason: Record<string, number> = {};
321
+
322
+ const purged = queue.filter((t) => {
323
+ if (t.status !== 'failed') return false;
324
+ const taskTime = new Date(t.timestamp || t.enqueued_at || 0).getTime();
325
+ if (!Number.isFinite(taskTime) || taskTime > cutoff) return false;
326
+ const reason = t.lastError || t.resolution || 'unknown';
327
+ byReason[reason] = (byReason[reason] || 0) + 1;
328
+ return true;
329
+ });
330
+
331
+ if (purged.length === 0) return { purged: 0, remaining: queue.length, byReason };
332
+
333
+ // Remove purged items from the queue (mutates in place)
334
+ const purgedIds = new Set(purged.map((t) => t.id));
335
+ for (let i = queue.length - 1; i >= 0; i--) {
336
+ if (purgedIds.has(queue[i].id)) queue.splice(i, 1);
337
+ }
338
+
339
+ const summary = Object.entries(byReason)
340
+ .map(([r, c]) => `${c}x ${r}`)
341
+ .join('; ');
342
+ logger?.info?.(`[PD:EvolutionWorker] Purged ${purged.length} stale failed tasks (>24h): ${summary}`);
343
+
344
+ return { purged: purged.length, remaining: queue.length, byReason };
345
+ }
346
+
347
+ function normalizePainDedupKey(source: string, preview: string, reason?: string): string {
348
+ // Include reason in dedup key to match createEvolutionTaskId() behavior
349
+ // Different reasons for the same source/preview should create different tasks
350
+ const normalizedReason = (reason || '').trim().toLowerCase();
351
+ return `${source.trim().toLowerCase()}::${preview.trim().toLowerCase()}::${normalizedReason}`;
352
+ }
353
+
354
+ export function hasRecentDuplicateTask(queue: EvolutionQueueItem[], source: string, preview: string, now: number, reason?: string): boolean {
355
+ return !!findRecentDuplicateTask(queue, source, preview, now, reason);
356
+ }
357
+
358
+ export function hasEquivalentPromotedRule(dictionary: { getAllRules(): Record<string, { type: string; phrases?: string[]; pattern?: string; status: string; }> }, phrase: string): boolean {
359
+ const normalizedPhrase = phrase.trim().toLowerCase();
360
+ return Object.values(dictionary.getAllRules()).some((rule) => {
361
+ if (rule.status !== 'active') return false;
362
+ if (rule.type === 'exact_match' && Array.isArray(rule.phrases)) {
363
+ return rule.phrases.some((candidate) => candidate.trim().toLowerCase() === normalizedPhrase);
364
+ }
365
+ if (rule.type === 'regex' && typeof rule.pattern === 'string') {
366
+ return rule.pattern.trim().toLowerCase() === normalizedPhrase;
367
+ }
368
+ return false;
369
+ });
370
+ }
371
+
372
+ /**
373
+ * Read recent pain context from PAIN_FLAG file.
374
+ * Returns structured pain metadata for attaching to sleep_reflection tasks.
375
+ * Returns null if no pain flag exists.
376
+ */
377
+ function readRecentPainContext(wctx: WorkspaceContext): RecentPainContext {
378
+ const painFlagPath = wctx.resolve('PAIN_FLAG');
379
+ if (!fs.existsSync(painFlagPath)) {
380
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
381
+ }
382
+
383
+ try {
384
+ const rawPain = fs.readFileSync(painFlagPath, 'utf8');
385
+ const lines = rawPain.split('\n');
386
+
387
+ let score = 0;
388
+ let source = '';
389
+ let reason = '';
390
+ let timestamp = '';
391
+
392
+ for (const line of lines) {
393
+ if (line.startsWith('score:')) score = parseInt(line.split(':', 2)[1].trim(), 10) || 0;
394
+ if (line.startsWith('source:')) source = line.split(':', 2)[1].trim();
395
+ if (line.startsWith('reason:')) reason = line.slice('reason:'.length).trim();
396
+ if (line.startsWith('timestamp:')) timestamp = line.slice('timestamp:'.length).trim();
397
+ }
398
+
399
+ if (score > 0) {
400
+ return {
401
+ mostRecent: { score, source, reason, timestamp },
402
+ recentPainCount: 1,
403
+ recentMaxPainScore: score,
404
+ };
405
+ }
406
+ } catch {
407
+ // Best effort — non-fatal
408
+ }
409
+
410
+ return { mostRecent: null, recentPainCount: 0, recentMaxPainScore: 0 };
411
+ }
412
+
413
+ /**
414
+ * Enqueue a sleep_reflection task if one is not already pending.
415
+ * Phase 2.4: Called when workspace is idle to trigger nocturnal reflection.
416
+ */
417
+ async function enqueueSleepReflectionTask(
418
+ wctx: WorkspaceContext,
419
+ logger: PluginLogger
420
+ ): Promise<void> {
421
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
422
+ const releaseLock = await requireQueueLock(queuePath, logger, 'enqueueSleepReflection', EVOLUTION_QUEUE_LOCK_SUFFIX);
423
+
424
+ try {
425
+ let rawQueue: RawQueueItem[] = [];
426
+ try {
427
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
428
+ } catch {
429
+ // Queue doesn't exist yet - create empty array
430
+ rawQueue = [];
431
+ }
432
+
433
+ const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
434
+
435
+ // Check if a sleep_reflection task is already pending
436
+ const hasPendingSleepReflection = queue.some(
437
+ t => t.taskKind === 'sleep_reflection' && (t.status === 'pending' || t.status === 'in_progress')
438
+ );
439
+ if (hasPendingSleepReflection) {
440
+ logger?.debug?.('[PD:EvolutionWorker] sleep_reflection task already pending/in-progress, skipping');
441
+ return;
442
+ }
443
+
444
+ const now = Date.now();
445
+ const taskId = createEvolutionTaskId('nocturnal', 50, 'idle workspace', 'Sleep-mode reflection', now);
446
+ const nowIso = new Date(now).toISOString();
447
+
448
+ // Attach recent pain context if available
449
+ const recentPainContext = readRecentPainContext(wctx);
450
+
451
+ queue.push({
452
+ id: taskId,
453
+ taskKind: 'sleep_reflection',
454
+ priority: 'medium',
455
+ score: 50,
456
+ source: 'nocturnal',
457
+ reason: 'Sleep-mode reflection triggered by idle workspace',
458
+ trigger_text_preview: 'Idle workspace detected',
459
+ timestamp: nowIso,
460
+ enqueued_at: nowIso,
461
+ status: 'pending',
462
+ traceId: taskId,
463
+ retryCount: 0,
464
+ maxRetries: 1, // sleep_reflection doesn't retry
465
+ recentPainContext,
466
+ });
467
+
468
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
469
+ logger?.info?.(`[PD:EvolutionWorker] Enqueued sleep_reflection task ${taskId}`);
470
+ } finally {
471
+ releaseLock();
472
+ }
473
+ }
474
+
475
+ interface ParsedPainValues {
476
+ score: number; source: string; reason: string; preview: string;
477
+ traceId: string; sessionId: string; agentId: string;
478
+ }
479
+
480
+ async function doEnqueuePainTask(
481
+ wctx: WorkspaceContext, logger: PluginLogger, painFlagPath: string,
482
+ result: WorkerStatusReport['pain_flag'], v: ParsedPainValues,
483
+ ): Promise<WorkerStatusReport['pain_flag']> {
484
+ result.exists = true;
485
+ result.score = v.score;
486
+ result.source = v.source;
487
+
488
+ if (v.score < 30) {
489
+ result.skipped_reason = `score_too_low (${v.score} < 30)`;
490
+ if (logger) logger.info(`[PD:EvolutionWorker] Pain flag score too low: ${v.score} (source=${v.source})`);
491
+ return result;
492
+ }
493
+
494
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
495
+ const releaseLock = await requireQueueLock(queuePath, logger, 'checkPainFlag');
496
+ try {
497
+ let queue: EvolutionQueueItem[] = [];
498
+ if (fs.existsSync(queuePath)) {
499
+ try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')); } catch {}
500
+ }
501
+ const now = Date.now();
502
+ const dup = findRecentDuplicateTask(queue, v.source, v.preview, now, v.reason);
503
+ if (dup) {
504
+ fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${dup.id}\n`, 'utf8');
505
+ result.enqueued = true;
506
+ result.skipped_reason = 'duplicate';
507
+ if (logger) logger.info(`[PD:EvolutionWorker] Duplicate pain task skipped for source=${v.source} preview=${v.preview || 'N/A'}`);
508
+ return result;
509
+ }
510
+
511
+ const taskId = createEvolutionTaskId(v.source, v.score, v.preview, v.reason, now);
512
+ const nowIso = new Date(now).toISOString();
513
+ const effectiveTraceId = v.traceId || taskId;
514
+
515
+ queue.push({
516
+ id: taskId, taskKind: 'pain_diagnosis',
517
+ priority: v.score >= 70 ? 'high' : v.score >= 40 ? 'medium' : 'low',
518
+ score: v.score, source: v.source, reason: v.reason,
519
+ trigger_text_preview: v.preview, timestamp: nowIso, enqueued_at: nowIso,
520
+ status: 'pending', session_id: v.sessionId || undefined,
521
+ agent_id: v.agentId || undefined, traceId: effectiveTraceId,
522
+ retryCount: 0, maxRetries: 3,
523
+ });
524
+
525
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
526
+ fs.appendFileSync(painFlagPath, `\nstatus: queued\ntask_id: ${taskId}\n`, 'utf8');
527
+ result.enqueued = true;
528
+
529
+ if (logger) logger.info(`[PD:EvolutionWorker] Enqueued pain task ${taskId} (score=${v.score})`);
530
+
531
+ const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
532
+ evoLogger.logQueued({
533
+ traceId: effectiveTraceId,
534
+ taskId,
535
+ score: v.score,
536
+ source: v.source,
537
+ reason: v.reason,
538
+ });
539
+
540
+ wctx.trajectory?.recordEvolutionTask?.({
541
+ taskId,
542
+ traceId: effectiveTraceId,
543
+ source: v.source,
544
+ reason: v.reason,
545
+ score: v.score,
546
+ status: 'pending',
547
+ enqueuedAt: nowIso,
548
+ });
549
+ } finally { releaseLock(); }
550
+ return result;
551
+ }
552
+
553
+ async function checkPainFlag(wctx: WorkspaceContext, logger: PluginLogger): Promise<WorkerStatusReport['pain_flag']> {
554
+ const result: WorkerStatusReport['pain_flag'] = { exists: false, score: null, source: null, enqueued: false, skipped_reason: null };
555
+ try {
556
+ const painFlagPath = wctx.resolve('PAIN_FLAG');
557
+ if (!fs.existsSync(painFlagPath)) return result;
558
+
559
+ const rawPain = fs.readFileSync(painFlagPath, 'utf8');
560
+
561
+ // Try JSON format first (pain skill structured output)
562
+ // The file may have 'status: queued' and 'task_id: xxx' appended after the JSON object.
563
+ // Extract just the JSON portion by finding the last '}' and parsing up to that point.
564
+ try {
565
+ const jsonEndIdx = rawPain.lastIndexOf('}');
566
+ const jsonPortion = jsonEndIdx >= 0 ? rawPain.slice(0, jsonEndIdx + 1) : rawPain;
567
+ const jsonPain = JSON.parse(jsonPortion);
568
+ if (typeof jsonPain === 'object' && jsonPain !== null && jsonPain.pain_score !== undefined) {
569
+ const jsonScore = typeof jsonPain.pain_score === 'number' ? jsonPain.pain_score :
570
+ typeof jsonPain.score === 'number' ? jsonPain.score : 50;
571
+ const jsonSource = jsonPain.source || 'human';
572
+ const jsonReason = jsonPain.reason || jsonPain.requested_action || 'Systemic pain detected';
573
+ const jsonPreview = (jsonPain.symptoms || []).slice(0, 2).join('; ');
574
+
575
+ // Check if already queued by looking for 'status: queued' in the full file
576
+ const alreadyQueued = rawPain.includes('status: queued');
577
+ if (alreadyQueued) {
578
+ result.exists = true;
579
+ result.score = jsonScore;
580
+ result.source = jsonSource;
581
+ result.enqueued = true;
582
+ result.skipped_reason = 'already_queued';
583
+ if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${jsonScore}, source=${jsonSource})`);
584
+ return result;
585
+ }
586
+
587
+ return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
588
+ score: jsonScore, source: jsonSource, reason: jsonReason,
589
+ preview: jsonPreview, traceId: '', sessionId: '', agentId: '',
590
+ });
591
+ }
592
+ } catch { /* Not JSON — fall through to KV/Markdown parsing */ }
593
+
594
+ const lines = rawPain.split('\n');
595
+
596
+ let score = 0;
597
+ let source = 'unknown';
598
+ let reason = 'Systemic pain detected';
599
+ let preview = '';
600
+ let isQueued = false;
601
+ let traceId = '';
602
+ let sessionId = '';
603
+ let agentId = '';
604
+
605
+ for (const line of lines) {
606
+ if (line.startsWith('score:')) score = parseInt(line.split(':', 2)[1].trim(), 10) || 0;
607
+ if (line.startsWith('source:')) source = line.split(':', 2)[1].trim();
608
+ if (line.startsWith('reason:')) reason = line.slice('reason:'.length).trim();
609
+ if (line.startsWith('trigger_text_preview:')) preview = line.slice('trigger_text_preview:'.length).trim();
610
+ if (line.startsWith('status: queued')) isQueued = true;
611
+ if (line.startsWith('trace_id:')) traceId = line.split(':', 2)[1].trim();
612
+ if (line.startsWith('session_id:')) sessionId = line.slice('session_id:'.length).trim();
613
+ if (line.startsWith('agent_id:')) agentId = line.slice('agent_id:'.length).trim();
614
+
615
+ // Markdown format support (pain skill writes **Source**: xxx format)
616
+ const mdSource = line.match(/\*\*Source\*\*:\s*(.+)/);
617
+ if (mdSource) source = mdSource[1].trim();
618
+ const mdReason = line.match(/\*\*Reason\*\*:\s*(.+)/);
619
+ if (mdReason) reason = mdReason[1].trim();
620
+ const mdTime = line.match(/\*\*Time\*\*:\s*(.+)/);
621
+ if (mdTime) preview = `Human intervention at ${mdTime[1].trim()}`;
622
+ }
623
+
624
+ // Markdown format has no score — default to 50 for human intervention
625
+ if (score === 0 && source !== 'unknown') score = 50;
626
+
627
+ result.exists = true;
628
+ result.score = score;
629
+ result.source = source;
630
+ result.enqueued = isQueued;
631
+
632
+ if (isQueued) {
633
+ result.skipped_reason = 'already_queued';
634
+ if (logger) logger.info(`[PD:EvolutionWorker] Pain flag already queued (score=${score}, source=${source})`);
635
+ return result;
636
+ }
637
+
638
+ if (logger) logger.info(`[PD:EvolutionWorker] Detected pain flag (score: ${score}, source: ${source}). Enqueueing evolution task.`);
639
+
640
+ return doEnqueuePainTask(wctx, logger, painFlagPath, result, {
641
+ score, source, reason, preview,
642
+ traceId, sessionId, agentId,
643
+ });
644
+
645
+ } catch (err) {
646
+ if (logger) logger.warn(`[PD:EvolutionWorker] Error processing pain flag: ${String(err)}`);
647
+ result.skipped_reason = `error: ${String(err)}`;
648
+ }
649
+ return result;
650
+ }
651
+
652
+ async function processEvolutionQueue(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog, api?: OpenClawPluginApi) {
653
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
654
+ if (!fs.existsSync(queuePath)) {
655
+ logger?.debug?.('[PD:EvolutionWorker] No evolution queue file — nothing to process');
656
+ return;
657
+ }
658
+
659
+ const releaseLock = await requireQueueLock(queuePath, logger, 'processEvolutionQueue');
660
+ const evoLogger = getEvolutionLogger(wctx.workspaceDir, wctx.trajectory);
661
+ let lockReleased = false;
662
+
663
+ try {
664
+ let rawQueue: RawQueueItem[] = [];
665
+ try {
666
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
667
+ } catch (e) {
668
+ // Backup corrupted file instead of silently discarding
669
+ const backupPath = `${queuePath}.corrupted.${Date.now()}`;
670
+ try {
671
+ fs.renameSync(queuePath, backupPath);
672
+ if (logger) {
673
+ logger.error(`[PD:EvolutionWorker] Evolution queue corrupted and backed up to ${backupPath}. All pending tasks have been preserved in the backup file. Parse error: ${String(e)}`);
674
+ }
675
+ SystemLogger.log(wctx.workspaceDir, 'QUEUE_CORRUPTED', `Queue file backed up to ${backupPath}. Error: ${String(e)}`);
676
+ } catch (backupErr) {
677
+ if (logger) {
678
+ logger.error(`[PD:EvolutionWorker] Failed to backup corrupted queue: ${String(backupErr)}`);
679
+ }
680
+ }
681
+ return;
682
+ }
683
+
684
+ // V2: Migrate queue to current schema if needed
685
+ const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
686
+
687
+ let queueChanged = rawQueue.some(isLegacyQueueItem);
688
+
689
+ const config = wctx.config;
690
+ const timeout = config.get('intervals.task_timeout_ms') || (60 * 60 * 1000); // Default 1 hour
691
+
692
+ // V2: Recover stuck in_progress sleep_reflection tasks.
693
+ // If the worker crashes or the result write-back fails after Phase 1 claimed
694
+ // the task, it stays in_progress indefinitely. Detect via timeout and mark
695
+ // as failed so a fresh task can be enqueued on the next idle cycle.
696
+ for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'sleep_reflection')) {
697
+ const startedAt = new Date(task.started_at || task.timestamp);
698
+ const age = Date.now() - startedAt.getTime();
699
+ if (age > timeout) {
700
+ task.status = 'failed';
701
+ task.completed_at = new Date().toISOString();
702
+ task.resolution = 'failed_max_retries';
703
+ task.lastError = `sleep_reflection timed out after ${Math.round(timeout / 60000)} minutes`;
704
+ task.retryCount = (task.retryCount ?? 0) + 1;
705
+ queueChanged = true;
706
+ logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${task.id} timed out after ${Math.round(age / 60000)} minutes, marking as failed`);
707
+ evoLogger.logCompleted({
708
+ traceId: task.traceId || task.id,
709
+ taskId: task.id,
710
+ resolution: 'manual',
711
+ durationMs: age,
712
+ });
713
+ }
714
+ }
715
+
716
+ // Check in_progress tasks for completion (only pain_diagnosis gets HEARTBEAT treatment)
717
+ // Diagnostician runs via HEARTBEAT (main session LLM), not as a subagent.
718
+ // Marker file detection is the ONLY completion path for HEARTBEAT diagnostics.
719
+ for (const task of queue.filter(t => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis')) {
720
+ const startedAt = new Date(task.started_at || task.timestamp);
721
+
722
+ // Condition 1: Check for marker file (created by diagnostician on completion)
723
+ const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
724
+ if (fs.existsSync(completeMarker)) {
725
+ if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} completed - marker file detected`);
726
+
727
+ // Create principle from the diagnostician's JSON report.
728
+ const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
729
+ if (fs.existsSync(reportPath)) {
730
+ try {
731
+ const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
732
+ // Check ALL known nesting paths — matches subagent.ts parseDiagnosticianReport
733
+ const principle = reportData?.principle
734
+ || reportData?.phases?.principle_extraction?.principle
735
+ || reportData?.diagnosis_report?.principle
736
+ || reportData?.diagnosis_report?.phases?.principle_extraction?.principle;
737
+ if (principle?.trigger_pattern && principle?.action) {
738
+ // Check for duplicate principle (diagnostician may output existing principle)
739
+ if (principle.duplicate === true) {
740
+ logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping creation for task ${task.id}`);
741
+ task.status = 'completed';
742
+ task.completed_at = new Date().toISOString();
743
+ task.resolution = 'marker_detected';
744
+ } else {
745
+ logger.info(`[PD:EvolutionWorker] Creating principle from report for task ${task.id}`);
746
+ const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
747
+ painId: task.id,
748
+ painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
749
+ triggerPattern: principle.trigger_pattern,
750
+ action: principle.action,
751
+ source: task.source || 'heartbeat_diagnostician',
752
+ evaluability: principle.evaluability || 'manual_only',
753
+ abstractedPrinciple: principle.abstracted_principle,
754
+ });
755
+ if (principleId) {
756
+ logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from marker fallback for task ${task.id}`);
757
+ } else {
758
+ logger.warn(`[PD:EvolutionWorker] createPrincipleFromDiagnosis returned null for task ${task.id} (may be duplicate or blacklisted)`);
759
+ }
760
+ task.status = 'completed';
761
+ task.completed_at = new Date().toISOString();
762
+ task.resolution = 'marker_detected';
763
+ }
764
+ } else {
765
+ logger.warn(`[PD:EvolutionWorker] Diagnostician report for task ${task.id} missing principle fields — diagnostician did not produce a principle`);
766
+ }
767
+ } catch (err) {
768
+ logger.warn(`[PD:EvolutionWorker] Failed to parse diagnostician report for task ${task.id}: ${String(err)}`);
769
+ }
770
+ } else {
771
+ logger.warn(`[PD:EvolutionWorker] No diagnostician report found for completed task ${task.id} (expected: .diagnostician_report_${task.id}.json)`);
772
+ }
773
+
774
+ task.status = 'completed';
775
+ task.completed_at = new Date().toISOString();
776
+ task.resolution = 'marker_detected';
777
+ try {
778
+ fs.unlinkSync(completeMarker);
779
+ } catch {}
780
+
781
+ // Log to EvolutionLogger
782
+ const durationMs = task.started_at
783
+ ? Date.now() - new Date(task.started_at).getTime()
784
+ : undefined;
785
+ evoLogger.logCompleted({
786
+ traceId: task.traceId || task.id,
787
+ taskId: task.id,
788
+ resolution: 'marker_detected',
789
+ durationMs,
790
+ });
791
+
792
+ // Update evolution_tasks table
793
+ wctx.trajectory?.updateEvolutionTask?.(task.id, {
794
+ status: 'completed',
795
+ completedAt: task.completed_at,
796
+ resolution: 'marker_detected',
797
+ });
798
+
799
+ wctx.trajectory?.recordTaskOutcome({
800
+ sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
801
+ taskId: task.id,
802
+ outcome: 'ok',
803
+ summary: `Task ${task.id} completed - marker file detected.`
804
+ });
805
+ queueChanged = true;
806
+ continue;
807
+ }
808
+
809
+ const age = Date.now() - startedAt.getTime();
810
+ if (age > timeout) {
811
+ const timeoutMinutes = Math.round(timeout / 60000);
812
+
813
+ const completeMarker = path.join(wctx.stateDir, `.evolution_complete_${task.id}`);
814
+ const reportPath = path.join(wctx.stateDir, `.diagnostician_report_${task.id}.json`);
815
+
816
+ if (fs.existsSync(completeMarker) && fs.existsSync(reportPath)) {
817
+ if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} timed out but marker found — creating principle anyway`);
818
+ let principleCreated = false;
819
+ try {
820
+ const reportData = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
821
+ const principle = reportData?.principle
822
+ || reportData?.phases?.principle_extraction?.principle
823
+ || reportData?.diagnosis_report?.principle
824
+ || reportData?.diagnosis_report?.phases?.principle_extraction?.principle;
825
+ if (principle?.trigger_pattern && principle?.action) {
826
+ if (principle.duplicate === true) {
827
+ logger.info(`[PD:EvolutionWorker] Diagnostician marked principle as duplicate: ${principle.duplicate_of || 'unknown'} — skipping for task ${task.id}`);
828
+ } else {
829
+ const principleId = wctx.evolutionReducer.createPrincipleFromDiagnosis({
830
+ painId: task.id,
831
+ painType: task.source === 'Human Intervention' ? 'user_frustration' : 'tool_failure',
832
+ triggerPattern: principle.trigger_pattern,
833
+ action: principle.action,
834
+ source: task.source || 'heartbeat_diagnostician',
835
+ evaluability: principle.evaluability || 'manual_only',
836
+ abstractedPrinciple: principle.abstracted_principle,
837
+ });
838
+ if (principleId) {
839
+ logger.info(`[PD:EvolutionWorker] Created principle ${principleId} from late marker for task ${task.id}`);
840
+ principleCreated = true;
841
+ }
842
+ }
843
+ }
844
+ } catch (err) {
845
+ logger.warn(`[PD:EvolutionWorker] Failed to parse late diagnostician report for task ${task.id}: ${String(err)}`);
846
+ }
847
+ try { fs.unlinkSync(completeMarker); } catch {}
848
+ task.resolution = principleCreated ? 'late_marker_principle_created' : 'late_marker_no_principle';
849
+ } else {
850
+ if (logger) logger.info(`[PD:EvolutionWorker] Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout`);
851
+ task.resolution = 'auto_completed_timeout';
852
+ }
853
+
854
+ // Critical: mark task as completed so it doesn't get re-processed
855
+ task.status = 'completed';
856
+ task.completed_at = new Date().toISOString();
857
+
858
+ // Log to EvolutionLogger - use task.resolution, not hardcoded value
859
+ evoLogger.logCompleted({
860
+ traceId: task.traceId || task.id,
861
+ taskId: task.id,
862
+ resolution: task.resolution,
863
+ durationMs: age,
864
+ });
865
+
866
+ // Update evolution_tasks table - use task.resolution, not hardcoded value
867
+ wctx.trajectory?.updateEvolutionTask?.(task.id, {
868
+ status: 'completed',
869
+ completedAt: task.completed_at,
870
+ resolution: task.resolution,
871
+ });
872
+
873
+ wctx.trajectory?.recordTaskOutcome({
874
+ sessionId: task.assigned_session_key || 'heartbeat:diagnostician',
875
+ taskId: task.id,
876
+ outcome: 'timeout',
877
+ summary: `Task ${task.id} auto-completed after ${timeoutMinutes} minute timeout.`
878
+ });
879
+ queueChanged = true;
880
+ }
881
+ }
882
+
883
+ // V2: Process pain_diagnosis tasks FIRST (quick, inside lock),
884
+ // then sleep_reflection tasks (slow, lock released during execution).
885
+ // This order ensures pain tasks are never starved by long-running
886
+ // nocturnal reflection — sleep_reflection can safely return early
887
+ // because pain_diagnosis has already been handled.
888
+ const pendingTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'pain_diagnosis');
889
+
890
+ if (pendingTasks.length > 0) {
891
+ // V2: Also sort by priority within same score
892
+ const priorityWeight = { high: 3, medium: 2, low: 1 };
893
+ const highestScoreTask = pendingTasks.sort((a, b) => {
894
+ const scoreDiff = b.score - a.score;
895
+ if (scoreDiff !== 0) return scoreDiff;
896
+ return (priorityWeight[b.priority] || 2) - (priorityWeight[a.priority] || 2);
897
+ })[0];
898
+ const nowIso = new Date().toISOString();
899
+
900
+ const taskDescription = `Diagnose systemic pain [ID: ${highestScoreTask.id}]. Source: ${highestScoreTask.source}. Reason: ${highestScoreTask.reason}. ` +
901
+ `Trigger text: "${highestScoreTask.trigger_text_preview || 'N/A'}"`;
902
+
903
+ // Prepare HEARTBEAT content first
904
+ // Use shared diagnostician protocol (consistent with pd-diagnostician skill)
905
+ const heartbeatPath = wctx.resolve('HEARTBEAT');
906
+ const markerFilePath = path.join(wctx.stateDir, `.evolution_complete_${highestScoreTask.id}`);
907
+ const reportFilePath = path.join(wctx.stateDir, `.diagnostician_report_${highestScoreTask.id}.json`);
908
+
909
+ let existingPrinciplesRef = '';
910
+ try {
911
+ const activePrinciples = wctx.evolutionReducer.getActivePrinciples();
912
+ if (activePrinciples.length > 0) {
913
+ // Include all principles up to 20 — enough for duplicate detection
914
+ // without overwhelming the context window
915
+ const maxPrinciples = 20;
916
+ const included = activePrinciples.length > maxPrinciples
917
+ ? activePrinciples.slice(-maxPrinciples)
918
+ : activePrinciples;
919
+ const lines = included.map((p) => {
920
+ let line = `### ${p.id}: ${p.text}`;
921
+ if (p.priority && p.priority !== 'P1') line += ` [${p.priority}]`;
922
+ if (p.scope === 'domain' && p.domain) line += ` (domain: ${p.domain})`;
923
+ return line;
924
+ });
925
+ existingPrinciplesRef = `\n**Existing Principles for Duplicate Detection** (showing ${included.length}/${activePrinciples.length}):\n${lines.join('\n')}`;
926
+
927
+ // Also inject suggested rules from existing principles (if any)
928
+ const rulesByPrinciple = included.filter((p) => p.suggestedRules?.length);
929
+ if (rulesByPrinciple.length > 0) {
930
+ const ruleLines = rulesByPrinciple.flatMap((p) =>
931
+ (p.suggestedRules ?? []).map((r) => `- [${p.id}] **${r.name}**: ${r.action} (type: ${r.type}, enforce: ${r.enforcement})`),
932
+ );
933
+ existingPrinciplesRef += `\n\n**Suggested Rules from Existing Principles**:\n${ruleLines.join('\n')}`;
934
+ }
935
+ }
936
+ } catch {}
937
+
938
+ const heartbeatContent = [
939
+ `## Evolution Task [ID: ${highestScoreTask.id}]`,
940
+ ``,
941
+ `**Pain Score**: ${highestScoreTask.score}`,
942
+ `**Source**: ${highestScoreTask.source}`,
943
+ `**Reason**: ${highestScoreTask.reason}`,
944
+ `**Trigger**: "${highestScoreTask.trigger_text_preview || 'N/A'}"`,
945
+ `**Queued At**: ${highestScoreTask.enqueued_at || nowIso}`,
946
+ `**Session ID**: ${highestScoreTask.session_id || 'N/A'}`,
947
+ `**Agent ID**: ${highestScoreTask.agent_id || 'main'}`,
948
+ ``,
949
+ `---`,
950
+ ``,
951
+ `## Diagnostician Protocol`,
952
+ ``,
953
+ `You MUST use the **pd-diagnostician** skill for this task.`,
954
+ `Read the full skill definition and follow the 4-phase protocol (Evidence → Causal Chain → Classification → Principle Extraction) EXACTLY as specified.`,
955
+ `The skill defines the complete output contract — your JSON report MUST match the format specified in the skill.`,
956
+ ``,
957
+ `---`,
958
+ ``,
959
+ `After completing the analysis:`,
960
+ `1. Write your JSON diagnosis report to: ${reportFilePath}`,
961
+ ` The JSON structure MUST match the output format defined in the pd-diagnostician skill.`,
962
+ `2. Mark the task complete by creating a marker file: ${markerFilePath}`,
963
+ ` The marker file should contain: "diagnostic_completed: <timestamp>\\noutcome: <summary>"`,
964
+ `3. Replace this HEARTBEAT.md content with "HEARTBEAT_OK"`,
965
+ existingPrinciplesRef,
966
+ ].join('\n');
967
+
968
+ // Try to write HEARTBEAT.md FIRST
969
+ // Only mark task as in_progress after successful write to avoid stuck tasks
970
+ try {
971
+ fs.writeFileSync(heartbeatPath, heartbeatContent, 'utf8');
972
+ if (logger) logger.info(`[PD:EvolutionWorker] Wrote diagnostician task to HEARTBEAT.md for task ${highestScoreTask.id}`);
973
+
974
+ // HEARTBEAT write succeeded, now mark task as in_progress
975
+ highestScoreTask.task = taskDescription;
976
+ highestScoreTask.status = 'in_progress';
977
+ highestScoreTask.started_at = nowIso;
978
+ delete highestScoreTask.completed_at;
979
+ // Use placeholder so marker path can correlate task (no subagent spawned for HEARTBEAT)
980
+ // This fixes task_outcomes being empty for HEARTBEAT-triggered diagnostician runs
981
+ highestScoreTask.assigned_session_key = `heartbeat:diagnostician:${highestScoreTask.id}`;
982
+ queueChanged = true;
983
+
984
+ // Log to EvolutionLogger
985
+ evoLogger.logStarted({
986
+ traceId: highestScoreTask.traceId || highestScoreTask.id,
987
+ taskId: highestScoreTask.id,
988
+ });
989
+
990
+ // Update evolution_tasks table
991
+ wctx.trajectory?.updateEvolutionTask?.(highestScoreTask.id, {
992
+ status: 'in_progress',
993
+ startedAt: nowIso,
994
+ });
995
+
996
+ if (eventLog) {
997
+ eventLog.recordEvolutionTask({
998
+ taskId: highestScoreTask.id,
999
+ taskType: highestScoreTask.source,
1000
+ reason: highestScoreTask.reason
1001
+ });
1002
+ }
1003
+ } catch (heartbeatErr) {
1004
+ // HEARTBEAT write failed - keep task as pending for next cycle retry
1005
+ if (logger) logger.error(`[PD:EvolutionWorker] Failed to write HEARTBEAT.md for task ${highestScoreTask.id}: ${String(heartbeatErr)}. Task will remain pending for next cycle.`);
1006
+ SystemLogger.log(wctx.workspaceDir, 'HEARTBEAT_WRITE_FAILED', `Task ${highestScoreTask.id} HEARTBEAT write failed: ${String(heartbeatErr)}`);
1007
+ }
1008
+ }
1009
+
1010
+ // Phase 2.4: Process sleep_reflection tasks AFTER pain_diagnosis.
1011
+ // Claim tasks inside the lock, execute reflection outside the lock,
1012
+ // then re-acquire the lock to write results. This prevents the long-running
1013
+ // nocturnal reflection from blocking all other queue consumers.
1014
+ // Safe to return early here because pain_diagnosis was already handled above.
1015
+ const sleepReflectionTasks = queue.filter(t => t.status === 'pending' && t.taskKind === 'sleep_reflection');
1016
+ if (sleepReflectionTasks.length > 0) {
1017
+ // --- Phase 1: Claim tasks (inside lock) ---
1018
+ for (const sleepTask of sleepReflectionTasks) {
1019
+ sleepTask.status = 'in_progress';
1020
+ sleepTask.started_at = new Date().toISOString();
1021
+ }
1022
+ queueChanged = true;
1023
+
1024
+ // Write claimed state (includes any pain changes from above) and release lock
1025
+ if (queueChanged) {
1026
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
1027
+ }
1028
+ releaseLock();
1029
+ for (const sleepTask of sleepReflectionTasks) {
1030
+ try {
1031
+ logger?.info?.(`[PD:EvolutionWorker] Processing sleep_reflection task ${sleepTask.id}`);
1032
+
1033
+ // NOC-14: Use NocturnalWorkflowManager for sleep_reflection tasks
1034
+ // Lazy-create manager (needs runtimeAdapter from api)
1035
+ let nocturnalManager: NocturnalWorkflowManager | undefined;
1036
+ if (api) {
1037
+ nocturnalManager = new NocturnalWorkflowManager({
1038
+ workspaceDir: wctx.workspaceDir,
1039
+ stateDir: wctx.stateDir,
1040
+ logger: api.logger,
1041
+ runtimeAdapter: new OpenClawTrinityRuntimeAdapter(api),
1042
+ });
1043
+ } else {
1044
+ // Cannot create manager without api (runtimeAdapter required)
1045
+ sleepTask.status = 'failed';
1046
+ sleepTask.completed_at = new Date().toISOString();
1047
+ sleepTask.resolution = 'failed_max_retries';
1048
+ sleepTask.lastError = 'No API available to create NocturnalWorkflowManager';
1049
+ sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1050
+ logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} skipped: no API`);
1051
+ continue;
1052
+ }
1053
+
1054
+ // Start workflow via NocturnalWorkflowManager instead of direct executeNocturnalReflectionAsync
1055
+ // Pass taskId in metadata for correlation
1056
+ const workflowHandle = await nocturnalManager.startWorkflow(nocturnalWorkflowSpec, {
1057
+ parentSessionId: `sleep_reflection:${sleepTask.id}`,
1058
+ workspaceDir: wctx.workspaceDir,
1059
+ taskInput: {},
1060
+ metadata: {
1061
+ snapshot: sleepTask.recentPainContext ? {
1062
+ sessionId: sleepTask.id,
1063
+ sessionStart: sleepTask.timestamp,
1064
+ stats: { totalAssistantTurns: 0, totalToolCalls: 0, failureCount: 0, totalPainEvents: sleepTask.recentPainContext.recentPainCount, totalGateBlocks: 0 },
1065
+ recentPain: sleepTask.recentPainContext.mostRecent ? [sleepTask.recentPainContext.mostRecent] : [],
1066
+ } : undefined,
1067
+ principleId: 'default',
1068
+ taskId: sleepTask.id, // NOC-14: correlation ID for evolution worker
1069
+ },
1070
+ });
1071
+
1072
+ // Store workflowId on task for polling on subsequent cycles
1073
+ sleepTask.resultRef = workflowHandle.workflowId;
1074
+
1075
+ // Workflow is running asynchronously. Check if it completed in this cycle
1076
+ // by polling getWorkflowDebugSummary.
1077
+ const summary = await nocturnalManager.getWorkflowDebugSummary(workflowHandle.workflowId);
1078
+ if (summary) {
1079
+ if (summary.state === 'completed') {
1080
+ sleepTask.status = 'completed';
1081
+ sleepTask.completed_at = new Date().toISOString();
1082
+ sleepTask.resolution = 'marker_detected';
1083
+ sleepTask.resultRef = summary.metadata?.nocturnalResult ? 'trinity-draft' : workflowHandle.workflowId;
1084
+ logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow completed`);
1085
+ } else if (summary.state === 'terminal_error') {
1086
+ sleepTask.status = 'failed';
1087
+ sleepTask.completed_at = new Date().toISOString();
1088
+ sleepTask.resolution = 'failed_max_retries';
1089
+ const lastEvent = summary.recentEvents[summary.recentEvents.length - 1];
1090
+ sleepTask.lastError = `Workflow terminal_error: ${lastEvent?.reason ?? 'unknown'}`;
1091
+ sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1092
+ logger?.warn?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow failed: ${sleepTask.lastError}`);
1093
+ } else {
1094
+ // Workflow still active, keep task in_progress for next cycle
1095
+ logger?.info?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} workflow ${summary.state}, will poll again next cycle`);
1096
+ }
1097
+ }
1098
+ } catch (taskErr) {
1099
+ sleepTask.status = 'failed';
1100
+ sleepTask.completed_at = new Date().toISOString();
1101
+ sleepTask.resolution = 'failed_max_retries';
1102
+ sleepTask.lastError = String(taskErr);
1103
+ sleepTask.retryCount = (sleepTask.retryCount ?? 0) + 1;
1104
+ logger?.error?.(`[PD:EvolutionWorker] sleep_reflection task ${sleepTask.id} threw: ${taskErr}`);
1105
+ }
1106
+ }
1107
+
1108
+ // --- Phase 3: Write results back (re-acquire lock) ---
1109
+ try {
1110
+ const resultLock = await requireQueueLock(queuePath, logger, 'sleepReflectionResult');
1111
+ try {
1112
+ // Re-read queue to merge with any changes made while lock was released
1113
+ let freshQueue: (RawQueueItem | EvolutionQueueItem)[] = [];
1114
+ try {
1115
+ freshQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
1116
+ } catch { /* empty queue if corrupted */ }
1117
+
1118
+ // Merge: update tasks by ID
1119
+ for (const sleepTask of sleepReflectionTasks) {
1120
+ const idx = freshQueue.findIndex((t) => (t as { id?: string }).id === sleepTask.id);
1121
+ if (idx >= 0) {
1122
+ freshQueue[idx] = sleepTask;
1123
+ }
1124
+ }
1125
+ fs.writeFileSync(queuePath, JSON.stringify(freshQueue, null, 2), 'utf8');
1126
+
1127
+ // Log completions to EvolutionLogger
1128
+ for (const sleepTask of sleepReflectionTasks) {
1129
+ if (sleepTask.status === 'completed' || sleepTask.status === 'failed') {
1130
+ evoLogger.logCompleted({
1131
+ traceId: sleepTask.traceId || sleepTask.id,
1132
+ taskId: sleepTask.id,
1133
+ resolution: sleepTask.status === 'completed'
1134
+ ? (sleepTask.resolution === 'marker_detected' ? 'marker_detected' : 'manual')
1135
+ : 'manual',
1136
+ durationMs: sleepTask.started_at
1137
+ ? Date.now() - new Date(sleepTask.started_at).getTime()
1138
+ : undefined,
1139
+ });
1140
+ }
1141
+ }
1142
+ } finally {
1143
+ resultLock();
1144
+ }
1145
+ } catch (resultLockErr) {
1146
+ // If we can't re-acquire lock, results are in memory but not persisted.
1147
+ // Tasks will appear stuck as in_progress and will be retried on next cycle.
1148
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to write sleep_reflection results back: ${String(resultLockErr)}`);
1149
+ }
1150
+
1151
+ // Safe to return — pain_diagnosis was already processed above.
1152
+ lockReleased = true;
1153
+ return;
1154
+ }
1155
+
1156
+ if (queueChanged) {
1157
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
1158
+ }
1159
+
1160
+ // Pipeline observability: log stage-level summary at end of cycle
1161
+ const pendingPain = queue.filter((t) => t.status === 'pending' && t.taskKind === 'pain_diagnosis').length;
1162
+ const inProgressPain = queue.filter((t) => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis').length;
1163
+ if (inProgressPain > 0) {
1164
+ const stuck = queue
1165
+ .filter((t) => t.status === 'in_progress' && t.taskKind === 'pain_diagnosis')
1166
+ .map((t) => `${t.id} (since ${t.started_at || 'unknown'})`);
1167
+ logger?.info?.(`[PD:EvolutionWorker] Pipeline: ${inProgressPain} pain_diagnosis task(s) in_progress — awaiting agent response: ${stuck.join(', ')}`);
1168
+ }
1169
+ if (pendingPain > 0) {
1170
+ logger?.info?.(`[PD:EvolutionWorker] Pipeline: ${pendingPain} pain_diagnosis task(s) pending — HEARTBEAT.md will trigger next cycle`);
1171
+ }
1172
+ const painCompleted = queue.filter((t) => t.status === 'completed' && t.taskKind === 'pain_diagnosis').length;
1173
+ logger?.info?.(`[PD:EvolutionWorker] Pipeline summary: pain_completed=${painCompleted} pain_pending=${pendingPain} pain_in_progress=${inProgressPain}`);
1174
+ } catch (err) {
1175
+ if (logger) logger.warn(`[PD:EvolutionWorker] Error processing evolution queue: ${String(err)}`);
1176
+ } finally {
1177
+ if (!lockReleased) {
1178
+ releaseLock();
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ async function processDetectionQueue(wctx: WorkspaceContext, api: OpenClawPluginApi, eventLog: EventLog) {
1184
+ const logger = api.logger;
1185
+ try {
1186
+ const funnel = DetectionService.get(wctx.stateDir);
1187
+ const queue = funnel.flushQueue();
1188
+ if (queue.length === 0) return;
1189
+
1190
+ if (logger) logger.info(`[PD:EvolutionWorker] Processing ${queue.length} items from detection funnel.`);
1191
+
1192
+ const dictionary = DictionaryService.get(wctx.stateDir);
1193
+
1194
+ for (const text of queue) {
1195
+ const match = dictionary.match(text);
1196
+ if (match) {
1197
+ if (eventLog) {
1198
+ eventLog.recordRuleMatch(undefined, {
1199
+ ruleId: match.ruleId,
1200
+ layer: 'L2',
1201
+ severity: match.severity,
1202
+ textPreview: text.substring(0, 100)
1203
+ });
1204
+ }
1205
+ } else {
1206
+ // L3 semantic search via trajectory database FTS5 (MEM-04)
1207
+ if (wctx.trajectory) {
1208
+ const searchResults = wctx.trajectory.searchPainEvents(text, 5);
1209
+ if (searchResults.length > 0) {
1210
+ // Found similar pain events - record as L3 semantic hit
1211
+ if (eventLog) {
1212
+ eventLog.recordRuleMatch(undefined, {
1213
+ ruleId: 'l3_semantic',
1214
+ layer: 'L3',
1215
+ severity: searchResults[0].score,
1216
+ textPreview: text.substring(0, 100)
1217
+ });
1218
+ }
1219
+ // Update detection funnel cache with L3 hit result
1220
+ funnel.updateCache(text, { detected: true, severity: searchResults[0].score });
1221
+ // Don't track as candidate - this is a confirmed L3 hit
1222
+ if (logger) logger.info(`[PD:EvolutionWorker] L3 semantic hit: found ${searchResults.length} similar pain events for "${text.substring(0, 50)}..."`);
1223
+ continue;
1224
+ }
1225
+ }
1226
+ // No L3 hit - fall through to track as pain candidate
1227
+ await trackPainCandidate(text, wctx);
1228
+ }
1229
+ }
1230
+ } catch (err) {
1231
+ if (logger) logger.warn(`[PD:EvolutionWorker] Detection queue failed: ${String(err)}`);
1232
+ }
1233
+ }
1234
+
1235
+ export async function trackPainCandidate(text: string, wctx: WorkspaceContext) {
1236
+ if (!shouldTrackPainCandidate(text)) return;
1237
+
1238
+ const candidatePath = wctx.resolve('PAIN_CANDIDATES');
1239
+ const releaseLock = await requireQueueLock(candidatePath, console, 'trackPainCandidate', PAIN_CANDIDATES_LOCK_SUFFIX);
1240
+
1241
+ try {
1242
+ let data: { candidates: Record<string, PainCandidateEntry> } = { candidates: {} };
1243
+ if (fs.existsSync(candidatePath)) {
1244
+ try {
1245
+ data = JSON.parse(fs.readFileSync(candidatePath, 'utf8'));
1246
+ } catch (e) {
1247
+ // Keep going with empty data if parse fails, but log it
1248
+ // eslint-disable-next-line no-console
1249
+ console.warn(`[PD:EvolutionWorker] Failed to parse pain candidates: ${String(e)}`);
1250
+ }
1251
+ }
1252
+
1253
+ const fingerprint = createPainCandidateFingerprint(text);
1254
+ const now = new Date().toISOString();
1255
+ if (!data.candidates[fingerprint]) {
1256
+ data.candidates[fingerprint] = { count: 0, status: 'pending', firstSeen: now, lastSeen: now, samples: [] };
1257
+ }
1258
+
1259
+ const cand = data.candidates[fingerprint];
1260
+ cand.status = cand.status || 'pending';
1261
+ cand.count++;
1262
+ cand.lastSeen = now;
1263
+
1264
+ const sample = summarizePainCandidateSample(text);
1265
+ if (cand.samples.length < PAIN_CANDIDATE_MAX_SAMPLES && !cand.samples.includes(sample)) {
1266
+ cand.samples.push(sample);
1267
+ }
1268
+
1269
+ fs.writeFileSync(candidatePath, JSON.stringify(data, null, 2), 'utf8');
1270
+ } finally {
1271
+ releaseLock();
1272
+ }
1273
+ }
1274
+
1275
+ export async function processPromotion(wctx: WorkspaceContext, logger: PluginLogger, eventLog: EventLog) {
1276
+ const candidatePath = wctx.resolve('PAIN_CANDIDATES');
1277
+ if (!fs.existsSync(candidatePath)) return;
1278
+
1279
+ const releaseLock = await requireQueueLock(candidatePath, logger, 'processPromotion', PAIN_CANDIDATES_LOCK_SUFFIX);
1280
+
1281
+ try {
1282
+ const config = wctx.config;
1283
+ const dictionary = wctx.dictionary;
1284
+ const data: { candidates: Record<string, PainCandidateEntry> } = JSON.parse(fs.readFileSync(candidatePath, 'utf8'));
1285
+ const countThreshold = config.get('thresholds.promotion_count_threshold') || 3;
1286
+
1287
+ let promotedCount = 0;
1288
+ let changed = false;
1289
+
1290
+ for (const [fingerprint, cand] of Object.entries(data.candidates)) {
1291
+ if (isPendingPainCandidate(cand.status) && cand.count >= countThreshold) {
1292
+ // Normalize undefined status to 'pending'
1293
+ if (cand.status !== 'pending') {
1294
+ cand.status = 'pending';
1295
+ changed = true;
1296
+ }
1297
+ const commonPhrases = extractCommonSubstring(cand.samples);
1298
+
1299
+ if (commonPhrases.length > 0) {
1300
+ const phrase = commonPhrases[0];
1301
+ const ruleId = `P_PROMOTED_${fingerprint.toUpperCase()}`;
1302
+
1303
+ if (hasEquivalentPromotedRule(dictionary, phrase)) {
1304
+ cand.status = 'duplicate';
1305
+ changed = true;
1306
+ logger?.info?.(`[PD:EvolutionWorker] Skipping duplicate promoted rule for candidate ${fingerprint}: ${phrase}`);
1307
+ continue;
1308
+ }
1309
+
1310
+ if (logger) logger.info(`[PD:EvolutionWorker] Promoting candidate ${fingerprint} to formal rule: ${ruleId}`);
1311
+ SystemLogger.log(wctx.workspaceDir, 'RULE_PROMOTED', `Candidate ${fingerprint} promoted to rule ${ruleId}`);
1312
+
1313
+ dictionary.addRule(ruleId, {
1314
+ type: 'exact_match',
1315
+ phrases: [phrase],
1316
+ severity: config.get('scores.default_confusion') || 35,
1317
+ status: 'active'
1318
+ });
1319
+
1320
+ cand.status = 'promoted';
1321
+ promotedCount++;
1322
+ changed = true;
1323
+ }
1324
+ }
1325
+ }
1326
+
1327
+ if (changed) {
1328
+ fs.writeFileSync(candidatePath, JSON.stringify(data, null, 2), 'utf8');
1329
+ }
1330
+ } catch (err) {
1331
+ if (logger) logger.warn(`[PD:EvolutionWorker] Error during rule promotion: ${String(err)}`);
1332
+ } finally {
1333
+ releaseLock();
1334
+ }
1335
+ }
1336
+
1337
+ export async function registerEvolutionTaskSession(
1338
+ workspaceResolve: (key: string) => string,
1339
+ taskId: string,
1340
+ sessionKey: string,
1341
+ logger?: { warn?: (message: string) => void; info?: (message: string) => void }
1342
+ ): Promise<boolean> {
1343
+ const queuePath = workspaceResolve('EVOLUTION_QUEUE');
1344
+ if (!fs.existsSync(queuePath)) return false;
1345
+
1346
+ const releaseLock = await requireQueueLock(queuePath, logger, 'registerEvolutionTaskSession');
1347
+
1348
+ try {
1349
+ let rawQueue: RawQueueItem[];
1350
+ try {
1351
+ rawQueue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
1352
+ } catch (parseErr) {
1353
+ logger?.warn?.(`[PD:EvolutionWorker] Failed to parse EVOLUTION_QUEUE for session registration: ${queuePath} - ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
1354
+ return false;
1355
+ }
1356
+
1357
+ // V2: Migrate queue to current schema
1358
+ const queue: EvolutionQueueItem[] = migrateQueueToV2(rawQueue);
1359
+
1360
+ const task = queue.find((item) => item.id === taskId && item.status === 'in_progress');
1361
+ if (!task) {
1362
+ logger?.warn?.(`[PD:EvolutionWorker] Could not find in-progress evolution task ${taskId} for session assignment`);
1363
+ return false;
1364
+ }
1365
+
1366
+ task.assigned_session_key = sessionKey;
1367
+ if (!task.started_at) {
1368
+ task.started_at = new Date().toISOString();
1369
+ }
1370
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
1371
+ return true;
1372
+ } finally {
1373
+ releaseLock();
1374
+ }
1375
+ }
1376
+
1377
+ /**
1378
+ * Evolution Worker - Background service for pain processing and evolution task management.
1379
+ *
1380
+ * IMPORTANT: evolution_directive.json is a COMPATIBILITY-ONLY DISPLAY ARTIFACT.
1381
+ * This service does NOT read or use directive for Phase 3 eligibility or any decisions.
1382
+ * Queue (EVOLUTION_QUEUE) is the only authoritative execution truth source.
1383
+ *
1384
+ * Directive exists solely for UI/backwards compatibility display purposes.
1385
+ * Production evidence shows directive stopped updating on 2026-03-22 and is stale.
1386
+ */
1387
+
1388
+ export interface ExtendedEvolutionWorkerService {
1389
+ id: string;
1390
+ api: OpenClawPluginApi | null;
1391
+ start: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
1392
+ stop?: (ctx: OpenClawPluginServiceContext) => void | Promise<void>;
1393
+ }
1394
+
1395
+ interface WorkerStatusReport {
1396
+ timestamp: string;
1397
+ cycle_start_ms: number;
1398
+ duration_ms: number;
1399
+ pain_flag: { exists: boolean; score: number | null; source: string | null; enqueued: boolean; skipped_reason: string | null };
1400
+ queue: { total: number; pending: number; in_progress: number; completed_this_cycle: number; failed_this_cycle: number };
1401
+ errors: string[];
1402
+ }
1403
+
1404
+ function writeWorkerStatus(stateDir: string, report: WorkerStatusReport): void {
1405
+ try {
1406
+ const statusPath = path.join(stateDir, 'worker-status.json');
1407
+ fs.writeFileSync(statusPath, JSON.stringify(report, null, 2), 'utf8');
1408
+ } catch {}
1409
+ }
1410
+
1411
+ async function processEvolutionQueueWithResult(
1412
+ wctx: WorkspaceContext,
1413
+ logger: PluginLogger,
1414
+ eventLog: EventLog,
1415
+ api?: OpenClawPluginApi | undefined
1416
+ ): Promise<{ queue: WorkerStatusReport['queue']; errors: string[] }> {
1417
+ const queueResult: WorkerStatusReport['queue'] = { total: 0, pending: 0, in_progress: 0, completed_this_cycle: 0, failed_this_cycle: 0 };
1418
+ const errors: string[] = [];
1419
+
1420
+ try {
1421
+ const queuePath = wctx.resolve('EVOLUTION_QUEUE');
1422
+ if (!fs.existsSync(queuePath)) {
1423
+ return { queue: queueResult, errors };
1424
+ }
1425
+
1426
+ const queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
1427
+
1428
+ // Purge stale failed tasks before processing (keeps queue lean)
1429
+ const purgeResult = purgeStaleFailedTasks(queue, logger);
1430
+ if (purgeResult.purged > 0) {
1431
+ // Write back the cleaned queue
1432
+ fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2), 'utf8');
1433
+ }
1434
+
1435
+ queueResult.total = queue.length;
1436
+ queueResult.pending = queue.filter((t: any) => t.status === 'pending').length;
1437
+ queueResult.in_progress = queue.filter((t: any) => t.status === 'in_progress').length;
1438
+ queueResult.failed_this_cycle = queue.filter((t: any) => t.status === 'failed').length;
1439
+ queueResult.completed_this_cycle = queue.filter((t: any) => t.status === 'completed').length;
1440
+
1441
+ // Log queue health snapshot every cycle
1442
+ logger.info(`[PD:EvolutionWorker] Queue snapshot: total=${queueResult.total} pending=${queueResult.pending} in_progress=${queueResult.in_progress} completed=${queueResult.completed_this_cycle} failed=${queueResult.failed_this_cycle} purged=${purgeResult.purged}`);
1443
+
1444
+ await processEvolutionQueue(wctx, logger, eventLog, api);
1445
+ } catch (err) {
1446
+ const errMsg = `processEvolutionQueue failed: ${String(err)}`;
1447
+ errors.push(errMsg);
1448
+ logger.error(`[PD:EvolutionWorker] ${errMsg}`);
1449
+ }
1450
+
1451
+ return { queue: queueResult, errors };
1452
+ }
1453
+
1454
+ export const EvolutionWorkerService: ExtendedEvolutionWorkerService = {
1455
+ id: 'principles-evolution-worker',
1456
+ api: null,
1457
+
1458
+ start(ctx: OpenClawPluginServiceContext): void {
1459
+ const logger = ctx?.logger || console;
1460
+ const api = this.api;
1461
+ const workspaceDir = ctx?.workspaceDir;
1462
+
1463
+ if (!workspaceDir) {
1464
+ if (logger) logger.warn('[PD:EvolutionWorker] workspaceDir not found in service config. Evolution cycle disabled.');
1465
+ return;
1466
+ }
1467
+
1468
+ const wctx = WorkspaceContext.fromHookContext({ workspaceDir, ...ctx.config });
1469
+ if (logger) logger.info(`[PD:EvolutionWorker] Starting with workspaceDir=${wctx.workspaceDir}, stateDir=${wctx.stateDir}`);
1470
+
1471
+ initPersistence(wctx.stateDir);
1472
+ const eventLog = wctx.eventLog;
1473
+
1474
+ const config = wctx.config;
1475
+ const language = config.get('language') || 'en';
1476
+ ensureStateTemplates({ logger }, wctx.stateDir, language);
1477
+
1478
+ const initialDelay = 5000;
1479
+ const interval = config.get('intervals.worker_poll_ms') || (15 * 60 * 1000);
1480
+
1481
+ async function runCycle(): Promise<void> {
1482
+ const cycleStart = Date.now();
1483
+ const cycleResult: {
1484
+ timestamp: string;
1485
+ cycle_start_ms: number;
1486
+ duration_ms: number;
1487
+ pain_flag: { exists: boolean; score: number | null; source: string | null; enqueued: boolean; skipped_reason: string | null };
1488
+ queue: { total: number; pending: number; in_progress: number; completed_this_cycle: number; failed_this_cycle: number };
1489
+ errors: string[];
1490
+ } = {
1491
+ timestamp: new Date().toISOString(),
1492
+ cycle_start_ms: cycleStart,
1493
+ duration_ms: 0,
1494
+ pain_flag: { exists: false, score: null, source: null, enqueued: false, skipped_reason: null },
1495
+ queue: { total: 0, pending: 0, in_progress: 0, completed_this_cycle: 0, failed_this_cycle: 0 },
1496
+ errors: [],
1497
+ };
1498
+
1499
+ try {
1500
+ const idleResult = checkWorkspaceIdle(wctx.workspaceDir, {});
1501
+ logger?.info?.(`[PD:EvolutionWorker] HEARTBEAT cycle=${new Date().toISOString()} idle=${idleResult.isIdle} idleForMs=${idleResult.idleForMs} userActiveSessions=${idleResult.userActiveSessions} abandonedSessions=${idleResult.abandonedSessionIds.length} lastActivityEpoch=${idleResult.mostRecentActivityAt}`);
1502
+ if (idleResult.isIdle) {
1503
+ logger?.debug?.(`[PD:EvolutionWorker] Workspace idle (${idleResult.idleForMs}ms since last activity)`);
1504
+ const cooldown = checkCooldown(wctx.stateDir);
1505
+ if (!cooldown.globalCooldownActive && !cooldown.quotaExhausted) {
1506
+ enqueueSleepReflectionTask(wctx, logger).catch((err) => {
1507
+ logger?.error?.(`[PD:EvolutionWorker] Failed to enqueue sleep_reflection task: ${String(err)}`);
1508
+ });
1509
+ }
1510
+ } else {
1511
+ logger?.debug?.(`[PD:EvolutionWorker] Workspace active (last activity ${idleResult.idleForMs}ms ago)`);
1512
+ }
1513
+
1514
+ const painCheckResult = await checkPainFlag(wctx, logger);
1515
+ cycleResult.pain_flag = painCheckResult;
1516
+
1517
+ const queueResult = await processEvolutionQueueWithResult(wctx, logger, eventLog, api ?? undefined);
1518
+ cycleResult.queue = queueResult.queue;
1519
+ if (queueResult.errors) cycleResult.errors.push(...queueResult.errors);
1520
+
1521
+ // If pain flag was enqueued AND processEvolutionQueue wrote HEARTBEAT.md
1522
+ // with a diagnostician task, immediately trigger a heartbeat to start
1523
+ // the diagnostician without waiting for the next 15-minute interval.
1524
+ // Must run AFTER processEvolutionQueue — HEARTBEAT.md must be written first.
1525
+ if (painCheckResult.enqueued) {
1526
+ const canTrigger = !!api?.runtime?.system?.runHeartbeatOnce;
1527
+ logger.info(`[PD:EvolutionWorker] Pain flag enqueued — runHeartbeatOnce available: ${canTrigger} (api=${!!api}, runtime=${!!api?.runtime}, system=${!!api?.runtime?.system})`);
1528
+ if (canTrigger) {
1529
+ try {
1530
+ const hbResult = await api.runtime.system.runHeartbeatOnce({
1531
+ reason: `pd-pain-diagnosis: pain flag detected, starting diagnostician`,
1532
+ });
1533
+ logger.info(`[PD:EvolutionWorker] Immediate heartbeat result: status=${hbResult.status}${hbResult.status === 'ran' ? ` duration=${hbResult.durationMs}ms` : ''}${hbResult.status === 'skipped' || hbResult.status === 'failed' ? ` reason=${hbResult.reason}` : ''}`);
1534
+ if (hbResult.status === 'skipped' || hbResult.status === 'failed') {
1535
+ logger.warn(`[PD:EvolutionWorker] Immediate heartbeat was ${hbResult.status} (${hbResult.reason}). Diagnostician will start on next regular heartbeat cycle.`);
1536
+ }
1537
+ } catch (hbErr) {
1538
+ logger.warn(`[PD:EvolutionWorker] Failed to trigger immediate heartbeat: ${String(hbErr)}. Diagnostician will start on next regular heartbeat cycle.`);
1539
+ }
1540
+ } else {
1541
+ logger.warn(`[PD:EvolutionWorker] runHeartbeatOnce not available. Diagnostician will start on next regular heartbeat cycle.`);
1542
+ }
1543
+ }
1544
+
1545
+ if (api) {
1546
+ await processDetectionQueue(wctx, api, eventLog);
1547
+ }
1548
+ await processPromotion(wctx, logger, eventLog);
1549
+
1550
+ try {
1551
+ // Delegate to workflow managers' sweepExpiredWorkflows so that
1552
+ // session/transcript cleanup runs via driver.deleteSession().
1553
+ const subagentRuntime = api?.runtime?.subagent;
1554
+ if (subagentRuntime) {
1555
+ const empathyMgr = new EmpathyObserverWorkflowManager({
1556
+ workspaceDir: wctx.workspaceDir,
1557
+ logger: api.logger,
1558
+ subagent: subagentRuntime,
1559
+ });
1560
+ let swept = 0;
1561
+ try {
1562
+ swept += await empathyMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS);
1563
+ } finally {
1564
+ empathyMgr.dispose();
1565
+ }
1566
+
1567
+ const deepReflectMgr = new DeepReflectWorkflowManager({
1568
+ workspaceDir: wctx.workspaceDir,
1569
+ logger: api.logger,
1570
+ subagent: subagentRuntime,
1571
+ });
1572
+ try {
1573
+ swept += await deepReflectMgr.sweepExpiredWorkflows(WORKFLOW_TTL_MS);
1574
+ } finally {
1575
+ deepReflectMgr.dispose();
1576
+ }
1577
+
1578
+ if (swept > 0) {
1579
+ logger?.info?.(`[PD:EvolutionWorker] Swept ${swept} expired workflows (with session cleanup)`);
1580
+ }
1581
+ } else {
1582
+ // Fallback: if subagent runtime unavailable, mark as expired
1583
+ // but log that session cleanup was skipped.
1584
+ const workflowStore = new WorkflowStore({ workspaceDir: wctx.workspaceDir });
1585
+ const expiredWorkflows = workflowStore.getExpiredWorkflows(WORKFLOW_TTL_MS);
1586
+ for (const wf of expiredWorkflows) {
1587
+ workflowStore.updateWorkflowState(wf.workflow_id, 'expired');
1588
+ workflowStore.updateCleanupState(wf.workflow_id, 'failed');
1589
+ workflowStore.recordEvent(wf.workflow_id, 'swept', wf.state, 'expired', 'TTL expired (no runtime for session cleanup)', {});
1590
+ logger?.warn?.(`[PD:EvolutionWorker] Marked workflow ${wf.workflow_id} as expired but could not cleanup session (subagent runtime unavailable)`);
1591
+ }
1592
+ workflowStore.dispose();
1593
+ }
1594
+ } catch (sweepErr) {
1595
+ const errMsg = `Failed to sweep expired workflows: ${String(sweepErr)}`;
1596
+ cycleResult.errors.push(errMsg);
1597
+ logger?.warn?.(`[PD:EvolutionWorker] ${errMsg}`);
1598
+ }
1599
+
1600
+ wctx.dictionary.flush();
1601
+ flushAllSessions();
1602
+
1603
+ cycleResult.duration_ms = Date.now() - cycleStart;
1604
+ writeWorkerStatus(wctx.stateDir, cycleResult);
1605
+ } catch (err) {
1606
+ const errMsg = `Error in worker interval: ${String(err)}`;
1607
+ if (logger) logger.error(`[PD:EvolutionWorker] ${errMsg}`);
1608
+ writeWorkerStatus(wctx.stateDir, {
1609
+ timestamp: new Date().toISOString(),
1610
+ cycle_start_ms: cycleStart,
1611
+ duration_ms: Date.now() - cycleStart,
1612
+ pain_flag: { exists: false, score: null, source: null, enqueued: false, skipped_reason: null },
1613
+ queue: { total: 0, pending: 0, in_progress: 0, completed_this_cycle: 0, failed_this_cycle: 0 },
1614
+ errors: [errMsg],
1615
+ });
1616
+ }
1617
+
1618
+ timeoutId = setTimeout(runCycle, interval);
1619
+ }
1620
+
1621
+ timeoutId = setTimeout(() => {
1622
+ void (async () => {
1623
+ await checkPainFlag(wctx, logger);
1624
+ // Use the same pipeline as regular cycles (includes purge + observability)
1625
+ const queueResult = await processEvolutionQueueWithResult(wctx, logger, eventLog, api ?? undefined);
1626
+ if (queueResult.errors.length > 0) {
1627
+ queueResult.errors.forEach((e) => logger?.error?.(`[PD:EvolutionWorker] Startup cycle error: ${e}`));
1628
+ }
1629
+ if (api) {
1630
+ await processDetectionQueue(wctx, api, eventLog);
1631
+ }
1632
+ await processPromotion(wctx, logger, eventLog);
1633
+ timeoutId = setTimeout(runCycle, interval);
1634
+ })().catch((err) => {
1635
+ if (logger) logger.error(`[PD:EvolutionWorker] Startup worker cycle failed: ${String(err)}`);
1636
+ timeoutId = setTimeout(runCycle, interval);
1637
+ });
1638
+ }, initialDelay);
1639
+ },
1640
+
1641
+ stop(ctx: OpenClawPluginServiceContext): void {
1642
+ if (ctx?.logger) ctx.logger.info('[PD:EvolutionWorker] Stopping background service...');
1643
+ if (timeoutId) clearTimeout(timeoutId);
1644
+ flushAllSessions();
1645
+ }
1646
+ };